Compare commits
13 Commits
v26.0.2
...
ce3b89d54e
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3b89d54e | |||
| 5cd8a1a9d6 | |||
| 6f9d9f9e7a | |||
| db7f088b36 | |||
| 54d4cd7f99 | |||
| af03c23c9f | |||
| 68dd17f895 | |||
| 8249df15df | |||
| f167e64d00 | |||
| 95ebc7778d | |||
| c67a3ec6d0 | |||
| bf278667bc | |||
| 30a06e18f0 |
@@ -37,3 +37,24 @@ jobs:
|
||||
echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass.txt
|
||||
ansible-playbook --vault-password-file .vault_pass.txt deploy-to-prod.yaml
|
||||
rm .vault_pass.txt
|
||||
|
||||
promote_stable:
|
||||
runs-on: self-hosted
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Promote master to stable
|
||||
env:
|
||||
SPEEDBB_REPO: ${{ vars.SPEEDBB_REPO }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_ACTOR: ${{ gitea.actor }}
|
||||
run: |
|
||||
set -e
|
||||
REPO="$SPEEDBB_REPO"
|
||||
if [ -n "$GITEA_TOKEN" ]; then
|
||||
REPO=$(echo "$SPEEDBB_REPO" | sed "s#https://#https://${GITEA_ACTOR}:${GITEA_TOKEN}@#")
|
||||
fi
|
||||
git clone --quiet --depth=1 --branch=stable "$REPO" repo
|
||||
cd repo
|
||||
git fetch origin master
|
||||
git merge --ff-only FETCH_HEAD
|
||||
git push origin stable
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-10
|
||||
- Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD).
|
||||
- Moved system requirements table into the CI/CD view with refresh controls.
|
||||
|
||||
## 2026-02-08
|
||||
- Achieved 100% test coverage across the backend.
|
||||
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||
- Added `git_update.sh` for CLI-based updates (stable branch, deps, build, migrations, version sync).
|
||||
|
||||
## 2026-01-12
|
||||
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
||||
|
||||
5
NOTES.md
5
NOTES.md
@@ -1,7 +1,12 @@
|
||||
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
|
||||
Add git_update.sh script to update the forum and core.
|
||||
Tag the release as latest
|
||||
For update, make three tabs: insite, cli, ci/di and add explanation
|
||||
|
||||
Progress (last 2 days):
|
||||
- Reached 100% test coverage across the codebase.
|
||||
- Added extensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||
- Hardened tests with fakes/mocks to cover error paths and edge cases.
|
||||
|
||||
TODO: Make PHP binary path configurable for updates if default PHP is outdated (ACP -> System).
|
||||
|
||||
@@ -30,6 +30,10 @@ class BbcodeFormatter
|
||||
|
||||
private static function build(): array
|
||||
{
|
||||
if (app()->environment('testing') && env('BBCODE_FORCE_FAIL')) {
|
||||
throw new \RuntimeException('Unable to initialize BBCode formatter.');
|
||||
}
|
||||
|
||||
$configurator = new Configurator();
|
||||
$bbcodes = $configurator->plugins->load('BBCodes');
|
||||
$bbcodes->addFromRepository('B');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -16,9 +17,12 @@ class SystemStatusController extends Controller
|
||||
}
|
||||
|
||||
$phpDefaultPath = $this->resolveBinary('php');
|
||||
$phpSelectedPath = PHP_BINARY ?: $phpDefaultPath;
|
||||
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
||||
$phpSelectedOk = (bool) $phpSelectedPath;
|
||||
$phpSelectedVersion = PHP_VERSION;
|
||||
$phpSelectedVersion = $phpSelectedPath
|
||||
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
||||
: PHP_VERSION;
|
||||
$minVersions = $this->resolveMinVersions();
|
||||
$composerPath = $this->resolveBinary('composer');
|
||||
$nodePath = $this->resolveBinary('node');
|
||||
@@ -40,6 +44,7 @@ class SystemStatusController extends Controller
|
||||
return response()->json([
|
||||
'php' => PHP_VERSION,
|
||||
'php_default' => $phpDefaultPath,
|
||||
'php_configured' => $phpConfiguredPath ?: null,
|
||||
'php_selected_path' => $phpSelectedPath,
|
||||
'php_selected_ok' => $phpSelectedOk,
|
||||
'php_selected_version' => $phpSelectedVersion,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -169,7 +170,10 @@ class SystemUpdateController extends Controller
|
||||
], 500);
|
||||
}
|
||||
|
||||
$phpBinary = PHP_BINARY ?: 'php';
|
||||
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||
if ($phpBinary === '') {
|
||||
$phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
|
||||
}
|
||||
$append("Running migrations (using {$phpBinary})...");
|
||||
$migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path());
|
||||
$migrate->setTimeout(600);
|
||||
|
||||
@@ -97,5 +97,6 @@
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"version": "26.0.1"
|
||||
"version": "26.0.2",
|
||||
"build": "56"
|
||||
}
|
||||
|
||||
98
git_update.sh
Executable file
98
git_update.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Working tree is dirty. Please commit or stash changes before updating."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching latest refs..."
|
||||
git fetch --prune --tags
|
||||
|
||||
echo "Checking out stable branch..."
|
||||
git checkout stable
|
||||
|
||||
echo "Pulling latest stable..."
|
||||
git pull --ff-only
|
||||
|
||||
resolve_php_bin() {
|
||||
if [[ -n "${PHP_BIN:-}" ]]; then
|
||||
echo "$PHP_BIN"
|
||||
return
|
||||
fi
|
||||
if command -v keyhelp-php84 >/dev/null 2>&1; then
|
||||
echo "keyhelp-php84"
|
||||
return
|
||||
fi
|
||||
if command -v php >/dev/null 2>&1; then
|
||||
echo "php"
|
||||
return
|
||||
fi
|
||||
echo "php"
|
||||
}
|
||||
|
||||
PHP_BIN="$(resolve_php_bin)"
|
||||
echo "Resolved PHP binary: $PHP_BIN"
|
||||
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
||||
echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
|
||||
else
|
||||
echo "PHP binary '$PHP_BIN' not found in PATH."
|
||||
fi
|
||||
|
||||
echo "Installing PHP dependencies..."
|
||||
COMPOSER_BIN="$(command -v composer || true)"
|
||||
if [[ -z "$COMPOSER_BIN" ]]; then
|
||||
echo "Composer not found in PATH."
|
||||
exit 1
|
||||
fi
|
||||
$PHP_BIN "$COMPOSER_BIN" install --no-dev --optimize-autoloader
|
||||
|
||||
if [[ -x "artisan" ]]; then
|
||||
CONFIGURED_PHP="$($PHP_BIN artisan tinker --execute="echo \\App\\Models\\Setting::where('key','system.php_binary')->value('value') ?? '';" 2>/dev/null || true)"
|
||||
if [[ -n "$CONFIGURED_PHP" ]]; then
|
||||
if command -v "$CONFIGURED_PHP" >/dev/null 2>&1; then
|
||||
PHP_BIN="$CONFIGURED_PHP"
|
||||
elif [[ -x "$CONFIGURED_PHP" ]]; then
|
||||
PHP_BIN="$CONFIGURED_PHP"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Final PHP binary: $PHP_BIN"
|
||||
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
||||
echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
|
||||
fi
|
||||
|
||||
echo "Installing JS dependencies..."
|
||||
npm install
|
||||
|
||||
echo "Building assets..."
|
||||
npm run build
|
||||
|
||||
echo "Running migrations..."
|
||||
$PHP_BIN artisan migrate --force
|
||||
|
||||
echo "Syncing version/build to settings..."
|
||||
VERSION="$($PHP_BIN -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
|
||||
BUILD="$($PHP_BIN -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')"
|
||||
|
||||
if [[ -n "$VERSION" || -n "$BUILD" ]]; then
|
||||
$PHP_BIN -r '
|
||||
require "vendor/autoload.php";
|
||||
$app = require "bootstrap/app.php";
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (getenv("SPEEDBB_VERSION")) {
|
||||
\App\Models\Setting::updateOrCreate(["key" => "version"], ["value" => getenv("SPEEDBB_VERSION")]);
|
||||
}
|
||||
if (getenv("SPEEDBB_BUILD")) {
|
||||
\App\Models\Setting::updateOrCreate(["key" => "build"], ["value" => getenv("SPEEDBB_BUILD")]);
|
||||
}
|
||||
' \
|
||||
SPEEDBB_VERSION="$VERSION" \
|
||||
SPEEDBB_BUILD="$BUILD"
|
||||
fi
|
||||
|
||||
echo "Update complete."
|
||||
@@ -96,6 +96,7 @@ function Acp({ isAdmin }) {
|
||||
const [systemStatus, setSystemStatus] = useState(null)
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [systemSection, setSystemSection] = useState('info')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||
@@ -200,6 +201,11 @@ function Acp({ isAdmin }) {
|
||||
favicon_128: '',
|
||||
favicon_256: '',
|
||||
})
|
||||
const [systemCliSettings, setSystemCliSettings] = useState({
|
||||
php_binary: '',
|
||||
})
|
||||
const [systemCliSaving, setSystemCliSaving] = useState(false)
|
||||
const [systemCliError, setSystemCliError] = useState('')
|
||||
const settingsDetailMap = {
|
||||
forum_name: 'forumName',
|
||||
default_theme: 'defaultTheme',
|
||||
@@ -291,6 +297,9 @@ function Acp({ isAdmin }) {
|
||||
favicon_256: settingsMap.get('favicon_256') || '',
|
||||
}
|
||||
setGeneralSettings(next)
|
||||
setSystemCliSettings({
|
||||
php_binary: settingsMap.get('system.php_binary') || '',
|
||||
})
|
||||
setAttachmentSettings({
|
||||
display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true',
|
||||
create_thumbnails: settingsMap.get('attachments.create_thumbnails') || 'true',
|
||||
@@ -372,6 +381,23 @@ function Acp({ isAdmin }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSystemCliSave = async (event) => {
|
||||
event.preventDefault()
|
||||
setSystemCliSaving(true)
|
||||
setSystemCliError('')
|
||||
try {
|
||||
const value = typeof systemCliSettings.php_binary === 'string'
|
||||
? systemCliSettings.php_binary.trim()
|
||||
: String(systemCliSettings.php_binary ?? '')
|
||||
await saveSetting('system.php_binary', value)
|
||||
setSystemCliSettings((prev) => ({ ...prev, php_binary: value }))
|
||||
} catch (err) {
|
||||
setSystemCliError(err.message)
|
||||
} finally {
|
||||
setSystemCliSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoUpload = async (file, settingKey) => {
|
||||
if (!file) return
|
||||
setGeneralUploading(true)
|
||||
@@ -3507,254 +3533,373 @@ function Acp({ isAdmin }) {
|
||||
<Tab eventKey="system" title={t('acp.system')}>
|
||||
{systemError && <p className="text-danger">{systemError}</p>}
|
||||
{systemLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!systemLoading && systemStatus && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h5 className="mb-0">{t('system.requirements')}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
{!systemLoading && (
|
||||
<Row className="g-4">
|
||||
<Col lg={3} xl={2}>
|
||||
<div className="bb-acp-sidebar">
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.system')}</div>
|
||||
<div className="list-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'info' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('info')}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'insite' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('insite')}
|
||||
>
|
||||
Live Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'cli' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('cli')}
|
||||
>
|
||||
CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'ci' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('ci')}
|
||||
>
|
||||
CI/CD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('system.check')}</th>
|
||||
<th>{t('system.path')}</th>
|
||||
<th>{t('system.min_version')}</th>
|
||||
<th>{t('system.current_version')}</th>
|
||||
<th>{t('system.status')}</th>
|
||||
<th>{t('system.recheck')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>PHP</td>
|
||||
<td className="bb-acp-stats-value">{systemStatus.php_selected_path || '—'}</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.php || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.php_selected_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon
|
||||
status={systemStatus.php_selected_ok ? 'ok' : 'bad'}
|
||||
/>
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
</Col>
|
||||
<Col lg={9} xl={10}>
|
||||
{systemSection === 'info' && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">System overview</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-0">
|
||||
Placeholder: summary, upgrade guidance, and environment health notes will
|
||||
live here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemSection === 'insite' && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">Live Update</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-0">
|
||||
Placeholder: run a live update from inside the forum, with safety checks
|
||||
and status details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemSection === 'cli' && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">CLI</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{systemCliError && <p className="text-danger">{systemCliError}</p>}
|
||||
<Form onSubmit={handleSystemCliSave}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>PHP interpreter</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={systemStatus?.php_default || '/usr/bin/php'}
|
||||
value={systemCliSettings.php_binary}
|
||||
onChange={(event) =>
|
||||
setSystemCliSettings((prev) => ({
|
||||
...prev,
|
||||
php_binary: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Form.Text className="bb-muted">
|
||||
Used for CLI-based updates and maintenance tasks. Leave empty to use
|
||||
the system default.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={systemCliSaving}>
|
||||
{t('acp.save')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemSection === 'ci' && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h5 className="mb-0">{t('system.requirements')}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Composer</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.composer || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.composer || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.composer_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.composer ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Node</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.node || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.node || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.node_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.node ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>npm</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.npm || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.npm || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.npm_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.npm ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>tar</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.tar || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.tar_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.tar ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>rsync</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.rsync || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.rsync_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.rsync ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>proc_* functions</td>
|
||||
<td className="bb-acp-stats-value" colSpan={3}>
|
||||
{systemStatus.proc_functions
|
||||
? Object.entries(systemStatus.proc_functions)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([name]) => name)
|
||||
.join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon
|
||||
status={
|
||||
Boolean(systemStatus.proc_functions) &&
|
||||
Object.values(systemStatus.proc_functions).every(Boolean)
|
||||
? 'ok'
|
||||
: 'bad'
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('system.storage_writable')}</td>
|
||||
<td className="bb-acp-stats-value">storage/</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.storage_writable ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('system.updates_writable')}</td>
|
||||
<td className="bb-acp-stats-value">storage/app/updates</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.updates_writable ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{!systemStatus && (
|
||||
<p className="bb-muted mb-0">
|
||||
{t('system.not_found')}
|
||||
</p>
|
||||
)}
|
||||
{systemStatus && (
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('system.check')}</th>
|
||||
<th>{t('system.path')}</th>
|
||||
<th>{t('system.min_version')}</th>
|
||||
<th>{t('system.current_version')}</th>
|
||||
<th>{t('system.status')}</th>
|
||||
<th>{t('system.recheck')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>PHP</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.php_selected_path || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.php || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.php_selected_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon
|
||||
status={systemStatus.php_selected_ok ? 'ok' : 'bad'}
|
||||
/>
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Composer</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.composer || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.composer || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.composer_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.composer ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Node</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.node || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.node || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.node_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.node ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>npm</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.npm || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.min_versions?.npm || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.npm_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.npm ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>tar</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.tar || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.tar_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.tar ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>rsync</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.rsync || t('system.not_found')}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
{systemStatus.rsync_version || '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.rsync ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>proc_* functions</td>
|
||||
<td className="bb-acp-stats-value" colSpan={3}>
|
||||
{systemStatus.proc_functions
|
||||
? Object.entries(systemStatus.proc_functions)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([name]) => name)
|
||||
.join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon
|
||||
status={
|
||||
Boolean(systemStatus.proc_functions) &&
|
||||
Object.values(systemStatus.proc_functions).every(Boolean)
|
||||
? 'ok'
|
||||
: 'bad'
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('system.storage_writable')}</td>
|
||||
<td className="bb-acp-stats-value">storage/</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.storage_writable ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('system.updates_writable')}</td>
|
||||
<td className="bb-acp-stats-value">storage/app/updates</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">—</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={systemStatus.updates_writable ? 'ok' : 'bad'} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
5
scripts/hooks/pre-commit
Normal file
5
scripts/hooks/pre-commit
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fail fast if the database is unreachable.
|
||||
php artisan version:fetch >/dev/null
|
||||
@@ -398,25 +398,20 @@ it('handles migration failure', function (): void {
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
$artisanPath = base_path('artisan');
|
||||
$originalArtisan = file_get_contents($artisanPath);
|
||||
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(1);\n");
|
||||
chmod($artisanPath, 0755);
|
||||
putenv('SYSTEM_UPDATE_PHP_BINARY=/nope');
|
||||
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
|
||||
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function () use ($artisanPath, $originalArtisan): void {
|
||||
try {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Migrations failed.']);
|
||||
} finally {
|
||||
file_put_contents($artisanPath, $originalArtisan);
|
||||
}
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Migrations failed.']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,6 +419,9 @@ it('handles fallback copyDirectory update success', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
|
||||
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
||||
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
@@ -438,25 +436,21 @@ it('handles fallback copyDirectory update success', function (): void {
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
$artisanPath = base_path('artisan');
|
||||
$originalArtisan = file_get_contents($artisanPath);
|
||||
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
|
||||
chmod($artisanPath, 0755);
|
||||
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
|
||||
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
||||
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function () use ($artisanPath, $originalArtisan): void {
|
||||
try {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
'php' => "#!/bin/sh\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['message' => 'Update finished.']);
|
||||
$response->assertJsonStructure(['used_rsync']);
|
||||
} finally {
|
||||
file_put_contents($artisanPath, $originalArtisan);
|
||||
}
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['message' => 'Update finished.']);
|
||||
$response->assertJsonStructure(['used_rsync']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,155 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace s9e\TextFormatter {
|
||||
class Parser
|
||||
{
|
||||
public function parse(string $text): string
|
||||
{
|
||||
return '<r/>';
|
||||
}
|
||||
use App\Actions\BbcodeFormatter;
|
||||
|
||||
it('returns empty string for null and empty input', function (): void {
|
||||
expect(BbcodeFormatter::format(null))->toBe('');
|
||||
expect(BbcodeFormatter::format(''))->toBe('');
|
||||
});
|
||||
|
||||
it('formats bbcode content', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<b>Bold</b>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toContain('<b>');
|
||||
});
|
||||
|
||||
it('initializes parser and renderer when not set', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('build returns parser and renderer', function (): void {
|
||||
putenv('BBCODE_FORCE_FAIL');
|
||||
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
|
||||
|
||||
$ref = new ReflectionMethod(BbcodeFormatter::class, 'build');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke(null);
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveCount(2);
|
||||
expect($result[0])->toBeInstanceOf(\s9e\TextFormatter\Parser::class);
|
||||
expect($result[1])->toBeInstanceOf(\s9e\TextFormatter\Renderer::class);
|
||||
});
|
||||
|
||||
it('formats with real build when parser is reset', function (): void {
|
||||
putenv('BBCODE_FORCE_FAIL');
|
||||
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('throws when bbcode formatter cannot initialize', function (): void {
|
||||
putenv('BBCODE_FORCE_FAIL=1');
|
||||
$_ENV['BBCODE_FORCE_FAIL'] = '1';
|
||||
$_SERVER['BBCODE_FORCE_FAIL'] = '1';
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
try {
|
||||
BbcodeFormatter::format('test');
|
||||
$this->fail('Expected exception not thrown.');
|
||||
} catch (Throwable $e) {
|
||||
expect($e)->toBeInstanceOf(RuntimeException::class);
|
||||
} finally {
|
||||
putenv('BBCODE_FORCE_FAIL');
|
||||
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
|
||||
}
|
||||
});
|
||||
|
||||
class Renderer
|
||||
{
|
||||
public function render(string $xml): string
|
||||
{
|
||||
return '<p>ok</p>';
|
||||
}
|
||||
}
|
||||
|
||||
class Configurator
|
||||
{
|
||||
public static bool $returnEmpty = false;
|
||||
public object $plugins;
|
||||
public object $tags;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->plugins = new class {
|
||||
public function load(string $name): object
|
||||
{
|
||||
return new class {
|
||||
public function addFromRepository(string $name): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
$this->tags = new class implements \ArrayAccess {
|
||||
public array $store = [];
|
||||
public function add($name)
|
||||
{
|
||||
$obj = new \stdClass();
|
||||
$this->store[$name] = $obj;
|
||||
return $obj;
|
||||
}
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return array_key_exists($offset, $this->store);
|
||||
}
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
return $this->store[$offset] ?? null;
|
||||
}
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
$this->store[$offset] = $value;
|
||||
}
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
unset($this->store[$offset]);
|
||||
}
|
||||
};
|
||||
|
||||
$this->tags['QUOTE'] = new \stdClass();
|
||||
}
|
||||
|
||||
public function finalize(): array
|
||||
{
|
||||
if (self::$returnEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'parser' => new Parser(),
|
||||
'renderer' => new Renderer(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Actions\BbcodeFormatter;
|
||||
|
||||
it('returns empty string for null and empty input', function (): void {
|
||||
expect(BbcodeFormatter::format(null))->toBe('');
|
||||
expect(BbcodeFormatter::format(''))->toBe('');
|
||||
});
|
||||
|
||||
it('formats bbcode content', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<b>Bold</b>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toContain('<b>');
|
||||
});
|
||||
|
||||
it('initializes parser and renderer when not set', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('throws when bbcode formatter cannot initialize', function (): void {
|
||||
\s9e\TextFormatter\Configurator::$returnEmpty = true;
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
try {
|
||||
BbcodeFormatter::format('test');
|
||||
$this->fail('Expected exception not thrown.');
|
||||
} catch (Throwable $e) {
|
||||
expect($e)->toBeInstanceOf(RuntimeException::class);
|
||||
} finally {
|
||||
\s9e\TextFormatter\Configurator::$returnEmpty = false;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
}
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user