OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Leantime\Core\Events\Concerns\InteractsWithEvents;
|
||||
use Leantime\Core\Events\Concerns\InteractsWithFilters;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Fixture event mirroring a migrated domain event: typed payload plus the
|
||||
* `legacyHook: __FUNCTION__` discriminator pattern — each dispatch rebuilds the single
|
||||
* historical name of its emit site (never a static list of all sites).
|
||||
*/
|
||||
class FixtureThingUpdated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $thingId,
|
||||
private readonly ?string $legacyHook = null,
|
||||
) {}
|
||||
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
if ($this->legacyHook === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['leantime.domain.things.services.things.'.$this->legacyHook.'.thing_updated'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture event without legacy hooks (an event introduced after the class-based system).
|
||||
*/
|
||||
class FixtureThingCreated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
public function __construct(public readonly int $thingId) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture class-based listener (resolved through the container, handle() receives the
|
||||
* typed event object).
|
||||
*/
|
||||
class FixtureThingListener
|
||||
{
|
||||
public static array $received = [];
|
||||
|
||||
public function handle(FixtureThingUpdated $event): void
|
||||
{
|
||||
self::$received[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture invokable listener (no handle() method) to cover the __invoke fallback for
|
||||
* array-form registrations like [FixtureInvokableListener::class].
|
||||
*/
|
||||
class FixtureInvokableListener
|
||||
{
|
||||
public static ?object $received = null;
|
||||
|
||||
public function __invoke(FixtureThingCreated $event): void
|
||||
{
|
||||
self::$received = $event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture filter mirroring a migrated domain filter: payload plus typed context.
|
||||
*/
|
||||
class FixtureThingsFilter implements LeantimeFilter
|
||||
{
|
||||
use InteractsWithFilters;
|
||||
|
||||
public function __construct(public array $things, public readonly int $userId) {}
|
||||
|
||||
public function payload(): mixed
|
||||
{
|
||||
return $this->things;
|
||||
}
|
||||
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [
|
||||
'leantime.domain.things.services.things.getThings.filterThings',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ClassEventDispatchTest extends TestCase
|
||||
{
|
||||
private array $staticSnapshot = [];
|
||||
|
||||
private const STATIC_PROPS = [
|
||||
'eventRegistry',
|
||||
'filterRegistry',
|
||||
'available_hooks',
|
||||
'patternMatchCache',
|
||||
'compiledPatternCache',
|
||||
'eventRegistryVersion',
|
||||
'filterRegistryVersion',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach (self::STATIC_PROPS as $prop) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$this->staticSnapshot[$prop] = $property->getValue();
|
||||
}
|
||||
|
||||
FixtureThingListener::$received = [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach ($this->staticSnapshot as $prop => $value) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setValue(null, $value);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* A closure listener registered on the FQCN receives the bare typed event object.
|
||||
*/
|
||||
public function test_fqcn_closure_listener_receives_typed_event_object(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingUpdated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 42);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingUpdated::class, $received);
|
||||
$this->assertSame(42, $received->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A class-string listener registered on the FQCN is container-resolved and its
|
||||
* handle() method receives the typed event object. This is the cacheable
|
||||
* registration style new code should use (no closures).
|
||||
*/
|
||||
public function test_fqcn_class_listener_handle_receives_typed_event_object(): void
|
||||
{
|
||||
EventDispatcher::add_event_listener(FixtureThingUpdated::class, FixtureThingListener::class);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 7);
|
||||
|
||||
$this->assertCount(1, FixtureThingListener::$received);
|
||||
$this->assertSame(7, FixtureThingListener::$received[0]->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* An invokable listener registered in array form ([Class::class], no handle()
|
||||
* method) falls back to __invoke() — same as the string registration form.
|
||||
*/
|
||||
public function test_array_form_invokable_listener_falls_back_to_invoke(): void
|
||||
{
|
||||
FixtureInvokableListener::$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, [FixtureInvokableListener::class]);
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 11);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, FixtureInvokableListener::$received);
|
||||
$this->assertSame(11, FixtureInvokableListener::$received->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: a listener registered on the exact historical string name
|
||||
* fires and receives today's array payload (event properties + current_route +
|
||||
* currentEvent) — NOT the event object. Existing plugins keep working unchanged.
|
||||
*/
|
||||
public function test_legacy_string_listener_receives_legacy_array_payload(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.things.services.things.updateThing.thing_updated',
|
||||
function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
}
|
||||
);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 42, legacyHook: 'updateThing');
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertSame(42, $received['thingId']);
|
||||
$this->assertSame(
|
||||
'leantime.domain.things.services.things.updateThing.thing_updated',
|
||||
$received['currentEvent']
|
||||
);
|
||||
$this->assertArrayHasKey('current_route', $received);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: plugin wildcard subscriptions (leantime.domain.*.services.*)
|
||||
* match the legacy name of a class-based event — exactly ONCE per dispatch, because
|
||||
* each emit site contributes only its own historical name via the legacyHook
|
||||
* discriminator. Both historical names stay reachable from their respective sites.
|
||||
*/
|
||||
public function test_wildcard_listener_fires_once_per_dispatch_for_legacy_hook(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
|
||||
$this->assertSame(1, $called);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
|
||||
$this->assertSame(2, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: an exact subscriber to one historical site's name does
|
||||
* NOT fire when a different site emits the same logical event — per-site semantics
|
||||
* are preserved through the migration window.
|
||||
*/
|
||||
public function test_exact_legacy_listener_keeps_per_site_semantics(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.things.services.things.patchThing.thing_updated',
|
||||
function () use (&$called) {
|
||||
$called++;
|
||||
}
|
||||
);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
|
||||
$this->assertSame(0, $called);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
|
||||
$this->assertSame(1, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wildcard string listeners do NOT accidentally match the FQCN (backslashes and
|
||||
* case don't fit the dotted lowercase patterns).
|
||||
*/
|
||||
public function test_wildcard_listener_does_not_match_fqcn(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 1);
|
||||
|
||||
$this->assertSame(0, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* An event with no legacy hooks only reaches FQCN listeners.
|
||||
*/
|
||||
public function test_event_without_legacy_hooks_fires_fqcn_listener_only(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 9);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, $received);
|
||||
$this->assertContains(FixtureThingCreated::class, EventDispatcher::get_available_hooks()['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* FQCN listeners run in priority order, lower number first.
|
||||
*/
|
||||
public function test_fqcn_listeners_run_in_priority_order(): void
|
||||
{
|
||||
$order = [];
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
|
||||
$order[] = 30;
|
||||
}, 30);
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
|
||||
$order[] = 10;
|
||||
}, 10);
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 1);
|
||||
|
||||
$this->assertSame([10, 30], $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class filter: FQCN listeners thread the payload and receive the filter object as
|
||||
* typed context; the final payload is returned.
|
||||
*/
|
||||
public function test_class_filter_threads_payload_through_fqcn_listeners(): void
|
||||
{
|
||||
$receivedFilter = null;
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) use (&$receivedFilter) {
|
||||
$receivedFilter = $filter;
|
||||
$things[] = 'added-by-listener';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
|
||||
|
||||
$this->assertSame(['original', 'added-by-listener'], $result);
|
||||
$this->assertInstanceOf(FixtureThingsFilter::class, $receivedFilter);
|
||||
$this->assertSame(5, $receivedFilter->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: a filter listener on the historical string name receives
|
||||
* today's ($payload, $availableParams) signature — params include the filter's
|
||||
* public properties plus current_route/currentEvent — and its return value threads
|
||||
* into the final result, after FQCN listeners.
|
||||
*/
|
||||
public function test_class_filter_threads_payload_through_legacy_listeners(): void
|
||||
{
|
||||
$receivedParams = null;
|
||||
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) {
|
||||
$things[] = 'fqcn';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener(
|
||||
'leantime.domain.things.services.things.getThings.filterThings',
|
||||
function ($things, $params) use (&$receivedParams) {
|
||||
$receivedParams = $params;
|
||||
$things[] = 'legacy';
|
||||
|
||||
return $things;
|
||||
}
|
||||
);
|
||||
|
||||
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
|
||||
|
||||
// FQCN group runs first, then the legacy group threads its output.
|
||||
$this->assertSame(['original', 'fqcn', 'legacy'], $result);
|
||||
$this->assertSame(5, $receivedParams['userId']);
|
||||
$this->assertArrayHasKey('current_route', $receivedParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter with no listeners at all returns the payload unchanged.
|
||||
*/
|
||||
public function test_class_filter_without_listeners_returns_payload_unchanged(): void
|
||||
{
|
||||
$result = FixtureThingsFilter::dispatch(things: ['untouched'], userId: 1);
|
||||
|
||||
$this->assertSame(['untouched'], $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance apply() ergonomic returns the filtered payload too.
|
||||
*/
|
||||
public function test_class_filter_apply_instance_method(): void
|
||||
{
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things) {
|
||||
$things[] = 'applied';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
$filter = new FixtureThingsFilter(things: ['a'], userId: 2);
|
||||
|
||||
$this->assertSame(['a', 'applied'], $filter->apply());
|
||||
}
|
||||
|
||||
/**
|
||||
* Class events route correctly through Laravel's event() helper / the instance
|
||||
* dispatch() of the Dispatcher interface as well.
|
||||
*/
|
||||
public function test_class_event_routes_through_laravel_event_helper(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
event(new FixtureThingCreated(thingId: 3));
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, $received);
|
||||
$this->assertSame(3, $received->thingId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Core\WorkStructure\Events\StructureRegistered;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Fixture emitter that dispatches through the DispatchesEvents trait exactly like a
|
||||
* domain service does, so the auto-generated event names (lowercased FQCN + method +
|
||||
* raw hook) match the real runtime format.
|
||||
*/
|
||||
class CharacterizationEmitter
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
public function updateThing(): void
|
||||
{
|
||||
self::dispatchEvent('thing_updated', ['thingId' => 7]);
|
||||
}
|
||||
|
||||
public function filterThing(int $payload): mixed
|
||||
{
|
||||
return self::dispatchFilter('thing_filter', $payload, ['mode' => 'strict']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Characterization tests locking the CURRENT EventDispatcher behavior before the
|
||||
* class-based event bridge is added. These tests document the string-event contract
|
||||
* that existing plugins rely on; they must keep passing unchanged.
|
||||
*/
|
||||
class EventDispatcherCharacterizationTest extends TestCase
|
||||
{
|
||||
private array $staticSnapshot = [];
|
||||
|
||||
private const STATIC_PROPS = [
|
||||
'eventRegistry',
|
||||
'filterRegistry',
|
||||
'available_hooks',
|
||||
'patternMatchCache',
|
||||
'compiledPatternCache',
|
||||
'eventRegistryVersion',
|
||||
'filterRegistryVersion',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach (self::STATIC_PROPS as $prop) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$this->staticSnapshot[$prop] = $property->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach ($this->staticSnapshot as $prop => $value) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setValue(null, $value);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* The DispatchesEvents trait builds the full event name as
|
||||
* strtolower(FQCN with \ -> .) + '.' + emitting method + '.' + raw hook.
|
||||
* Plugins subscribe to exactly these strings — the format must not drift.
|
||||
*/
|
||||
public function test_trait_builds_full_event_name_from_class_and_method(): void
|
||||
{
|
||||
(new CharacterizationEmitter)->updateThing();
|
||||
|
||||
$this->assertContains(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
EventDispatcher::get_available_hooks()['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A listener registered on the full string name receives a SINGLE array argument:
|
||||
* the dispatched payload merged with current_route and currentEvent.
|
||||
*/
|
||||
public function test_string_event_listener_receives_define_params_array(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
}
|
||||
);
|
||||
|
||||
(new CharacterizationEmitter)->updateThing();
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertSame(7, $received['thingId']);
|
||||
$this->assertSame(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
$received['currentEvent']
|
||||
);
|
||||
$this->assertArrayHasKey('current_route', $received);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter listeners receive ($payload, $availableParams) where availableParams is the
|
||||
* emitter-provided context merged with current_route/currentEvent, and the payload is
|
||||
* threaded through listeners in priority order (lower priority number runs first).
|
||||
*/
|
||||
public function test_filter_threads_payload_in_priority_order_and_passes_params(): void
|
||||
{
|
||||
$fullName = 'unit.app.core.events.characterizationemitter.filterThing.thing_filter';
|
||||
$receivedParams = null;
|
||||
|
||||
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) use (&$receivedParams) {
|
||||
$receivedParams = $params;
|
||||
|
||||
return $payload + 1;
|
||||
}, 20);
|
||||
|
||||
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) {
|
||||
return $payload * 2;
|
||||
}, 10);
|
||||
|
||||
$result = (new CharacterizationEmitter)->filterThing(5);
|
||||
|
||||
// priority 10 runs first: 5 * 2 = 10, then priority 20: 10 + 1 = 11
|
||||
$this->assertSame(11, $result);
|
||||
$this->assertSame('strict', $receivedParams['mode']);
|
||||
$this->assertSame($fullName, $receivedParams['currentEvent']);
|
||||
$this->assertArrayHasKey('current_route', $receivedParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins rely on wildcard subscriptions (e.g. leantime.domain.*.services.*) matching
|
||||
* the auto-generated full names. The * wildcard must keep matching.
|
||||
*/
|
||||
public function test_wildcard_listener_matches_full_event_name(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
EventDispatcher::dispatch_event('leantime.domain.faux.services.faux.doIt.did_it', ['x' => 1], '');
|
||||
|
||||
$this->assertSame(1, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listeners for one hook run in priority order, lower number first.
|
||||
*/
|
||||
public function test_event_listeners_run_in_priority_order(): void
|
||||
{
|
||||
$order = [];
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 30;
|
||||
}, 30);
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 10;
|
||||
}, 10);
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 20;
|
||||
}, 20);
|
||||
|
||||
EventDispatcher::dispatch_event('char.priority.event', [], '');
|
||||
|
||||
$this->assertSame([10, 20, 30], $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current behavior for plain object events (Laravel Dispatchable path, e.g. the
|
||||
* WorkStructure events): the object resolves to its FQCN as the listener name and a
|
||||
* 'leantime' source listener receives the defineParams array with the object at [0].
|
||||
*/
|
||||
public function test_plain_object_event_fires_fqcn_string_listener(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(StructureRegistered::class, function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
});
|
||||
|
||||
StructureRegistered::dispatch(1, 'My Structure', 'system');
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertInstanceOf(StructureRegistered::class, $received[0]);
|
||||
$this->assertSame(1, $received[0]->structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern-match cache is invalidated when a listener is added (version counter),
|
||||
* so listeners registered after a first dispatch still fire on later dispatches.
|
||||
*/
|
||||
public function test_pattern_cache_busts_when_listener_added_after_dispatch(): void
|
||||
{
|
||||
$first = 0;
|
||||
$second = 0;
|
||||
|
||||
EventDispatcher::add_event_listener('char.cache.*', function () use (&$first) {
|
||||
$first++;
|
||||
});
|
||||
EventDispatcher::dispatch_event('char.cache.bust', [], '');
|
||||
|
||||
EventDispatcher::add_event_listener('char.cache.*', function () use (&$second) {
|
||||
$second++;
|
||||
});
|
||||
EventDispatcher::dispatch_event('char.cache.bust', [], '');
|
||||
|
||||
$this->assertSame(2, $first);
|
||||
$this->assertSame(1, $second);
|
||||
}
|
||||
}
|
||||
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Events\EventVerb;
|
||||
|
||||
/**
|
||||
* Enforces the shared event vocabulary across all domains:
|
||||
*
|
||||
* - event classes are named {Entity}{Verb} with the verb from the central EventVerb
|
||||
* enum (TicketCreated, MilestoneDeleted — never TicketChanged/TicketEdited)
|
||||
* - filter classes are named {Thing}Filter (TodoWidgetTasksFilter)
|
||||
*
|
||||
* Scans every class in app/Domain/* /Events and app/Core/* /Events that implements
|
||||
* LeantimeEvent or LeantimeFilter. Failing this test means a synonym crept in — use an
|
||||
* existing verb or (rarely) add one to EventVerb.
|
||||
*/
|
||||
class EventVocabularyTest extends Unit
|
||||
{
|
||||
public function test_event_class_names_end_with_central_vocabulary_verb(): void
|
||||
{
|
||||
$discovered = $this->discoverEventClasses();
|
||||
|
||||
// Guard against a vacuous pass: if discovery silently finds nothing (e.g. a
|
||||
// broken base path), the loop below would assert nothing. The pilot ships ten
|
||||
// contract classes in Tickets, so discovery must find them.
|
||||
$this->assertContains(
|
||||
\Leantime\Domain\Tickets\Events\TicketUpdated::class,
|
||||
$discovered,
|
||||
'Event class discovery found nothing — the vocabulary check would pass vacuously.'
|
||||
);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($discovered as $class) {
|
||||
$implements = class_implements($class);
|
||||
$shortName = substr($class, strrpos($class, '\\') + 1);
|
||||
|
||||
if (in_array(LeantimeFilter::class, $implements, true)) {
|
||||
if (! str_ends_with($shortName, 'Filter')) {
|
||||
$violations[] = "$class — filter classes must be named {Thing}Filter";
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(LeantimeEvent::class, $implements, true)) {
|
||||
$endsWithVerb = false;
|
||||
foreach (EventVerb::cases() as $verb) {
|
||||
if (str_ends_with($shortName, $verb->name)) {
|
||||
$endsWithVerb = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $endsWithVerb) {
|
||||
$violations[] = "$class — event classes must be named {Entity}{Verb} with a verb from EventVerb";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $violations, "Event vocabulary violations:\n".implode("\n", $violations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all classes in Domain and Core Events/ folders that implement one of the
|
||||
* class-based hook contracts.
|
||||
*
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
private function discoverEventClasses(): array
|
||||
{
|
||||
// Anchor on the canonical app-root constant rather than a brittle relative
|
||||
// dirname() hop, so the scan can't silently miss the Events folders.
|
||||
$appRoot = defined('APP_ROOT') ? APP_ROOT : dirname(__DIR__, 5);
|
||||
|
||||
$files = array_merge(
|
||||
glob($appRoot.'/app/Domain/*/Events/*.php') ?: [],
|
||||
glob($appRoot.'/app/Core/*/Events/*.php') ?: [],
|
||||
);
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$relative = str_replace([$appRoot.'/app/', '/', '.php'], ['', '\\', ''], $file);
|
||||
$class = 'Leantime\\'.$relative;
|
||||
|
||||
if (! class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$implements = class_implements($class) ?: [];
|
||||
if (in_array(LeantimeEvent::class, $implements, true)
|
||||
|| in_array(LeantimeFilter::class, $implements, true)) {
|
||||
$classes[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
}
|
||||
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
class EventsTest extends Unit
|
||||
{
|
||||
/**
|
||||
* This test will check the dispatch_event method of the EventDispatcher class.
|
||||
* It will dispatch an event and assert if it is added to the available_hooks array.
|
||||
*/
|
||||
public function test_dispatch_event()
|
||||
{
|
||||
$eventName = 'test.event.name';
|
||||
$payload = ['testKey' => 'testValue'];
|
||||
$context = 'testContext';
|
||||
|
||||
// Dispatch event
|
||||
EventDispatcher::dispatch_event($eventName, $payload, $context);
|
||||
|
||||
// Get all available hooks
|
||||
$available_hooks = EventDispatcher::get_available_hooks();
|
||||
|
||||
// Test that the dispatched event has been registered in available_hooks
|
||||
$this->assertContains("$context.$eventName", $available_hooks['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* This test will check the findEventListeners method of the EventDispatcher class.
|
||||
*/
|
||||
public function test_find_event_listeners()
|
||||
{
|
||||
$eventName = 'test.event.name';
|
||||
$listenerName = 'test.listener';
|
||||
$payload = ['testKey' => 'testValue'];
|
||||
$context = 'testContext';
|
||||
$eventListeners = [$listenerName => [$payload]];
|
||||
|
||||
EventDispatcher::add_event_listener($listenerName, function () {}, 10);
|
||||
// Test that the event listener has been found
|
||||
$this->assertEquals([$payload], EventDispatcher::findEventListeners($listenerName, $eventListeners));
|
||||
}
|
||||
|
||||
/**
|
||||
* This test will check the get_registries method of the EventDispatcher class.
|
||||
* It will add new event listener and a new filter listener and check both listeners
|
||||
* are in the registry arrays.
|
||||
*/
|
||||
public function test_get_registries()
|
||||
{
|
||||
$eventName = 'event.test.name';
|
||||
$filterName = 'filter.test.name';
|
||||
|
||||
// Add an event listener
|
||||
EventDispatcher::add_event_listener($eventName, function () {}, 10);
|
||||
|
||||
// Add a filter listener
|
||||
EventDispatcher::add_filter_listener($filterName, function () {}, 10);
|
||||
|
||||
// Get registries
|
||||
$registries = EventDispatcher::get_registries();
|
||||
|
||||
// Check registries
|
||||
$this->assertContains($eventName, $registries['events']);
|
||||
$this->assertContains($filterName, $registries['filters']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user