Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.29% covered (warning)
54.29%
38 / 70
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TrackingToolRegistry
54.29% covered (warning)
54.29%
38 / 70
66.67% covered (warning)
66.67%
6 / 9
102.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getRegistry
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getConfiguredPEDashboardData
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 getRegistryForTesting
n/a
0 / 0
n/a
0 / 0
2
 setRegistryForTesting
n/a
0 / 0
n/a
0 / 0
2
 newFromDBID
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getDataForForm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 newFromUserIdentifier
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 newFromRegistryEntry
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getUserInfo
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getToolEventIDFromURL
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\TrackingTool;
6
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
9use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
10use MediaWiki\Extension\CampaignEvents\TrackingTool\Tool\TrackingTool;
11use MediaWiki\Extension\CampaignEvents\TrackingTool\Tool\WikiEduDashboard;
12use MediaWiki\MainConfigNames;
13use RuntimeException;
14use Wikimedia\ObjectFactory\ObjectFactory;
15
16/**
17 * This is a registry of known tracking tools, which defines how each tool should be represented in the
18 * database and also acts as a factory for TrackingTool objects.
19 */
20class TrackingToolRegistry {
21    public const SERVICE_NAME = 'CampaignEventsTrackingToolRegistry';
22
23    public const CONSTRUCTOR_OPTIONS = [
24        'CampaignEventsProgramsAndEventsDashboardInstance',
25        'CampaignEventsProgramsAndEventsDashboardAPISecret',
26        MainConfigNames::CopyUploadProxy,
27    ];
28
29    private ObjectFactory $objectFactory;
30    private ServiceOptions $options;
31
32    /** @var array|null Mock registry that can be set in tests. */
33    private ?array $registryForTests = null;
34
35    /**
36     * @param ObjectFactory $objectFactory
37     * @param ServiceOptions $options
38     */
39    public function __construct( ObjectFactory $objectFactory, ServiceOptions $options ) {
40        $this->objectFactory = $objectFactory;
41        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
42        $this->options = $options;
43    }
44
45    /**
46     * This method returns the internal registry of known tracking tools. This list can potentially be affected by
47     * config. Array keys are only used as an internal representation for readability, and For each element
48     * the following keys must be set:
49     *  - display-name-msg (string): Key of i18n message with the display name of the tool
50     *  - base-url (string): Base URL of the tool's instance
51     *  - class (string): Name of the class that handles this tool (must extend the TrackingTool abstract class)
52     *  - db-id (int): ID of the tool stored in the DB
53     *  - user-id (string): Identifier of the tool as provided by users via the UI or API
54     *  - extra (array): Any additional information needed by the tool, like an API key. The structure of this
55     *    array is dependent on the class which uses it.
56     *  - services (array): List of services that should be injected into the object (before other arguments)
57     * Display names, user IDs, and DB IDs are guaranteed to be unique. Classes and base URLs are not, because
58     * potentially there could be more instances of the same tool, or a server could host more than one tool,
59     * respectively.
60     * @return array[]
61     * @phan-return array<array{display-name-msg:string,base-url:string,class:class-string,db-id:int,user-id:string,extra:array}>
62     */
63    private function getRegistry(): array {
64        if ( $this->registryForTests !== null ) {
65            return $this->registryForTests;
66        }
67
68        $registry = [];
69
70        $peDashboardData = $this->getConfiguredPEDashboardData();
71        if ( $peDashboardData !== null ) {
72            $registry['P&E Dashboard'] = $peDashboardData;
73        }
74
75        return $registry;
76    }
77
78    /**
79     * Returns the registry definition of the P&E Dashboard, if configured, and null otherwise.
80     *
81     * @return array{display-name-msg:string,base-url:string,class:class-string,db-id:int,user-id:string,extra:array}|null
82     */
83    private function getConfiguredPEDashboardData(): ?array {
84        $peDashboardInstance = $this->options->get( 'CampaignEventsProgramsAndEventsDashboardInstance' );
85        if ( $peDashboardInstance === null ) {
86            return null;
87        }
88
89        $dashboardUrl = $peDashboardInstance === 'production'
90            ? 'https://outreachdashboard.wmflabs.org/'
91                : 'https://dashboard-testing.wikiedu.org/';
92        $apiSecret = $this->options->get( 'CampaignEventsProgramsAndEventsDashboardAPISecret' );
93        if ( !is_string( $apiSecret ) ) {
94            throw new RuntimeException(
95                '"CampaignEventsProgramsAndEventsDashboardAPISecret" must be configured in order to ' .
96                    ' use the P&E Dashboard.'
97            );
98        }
99        return [
100            'display-name-msg' => 'campaignevents-tracking-tool-p&e-dashboard-name',
101            'base-url' => $dashboardUrl,
102            'class' => WikiEduDashboard::class,
103            'db-id' => 1,
104            'user-id' => 'wikimedia-pe-dashboard',
105            'extra' => [
106                'secret' => $apiSecret,
107                'proxy' => $this->options->get( MainConfigNames::CopyUploadProxy ) ?: null
108            ],
109            'services' => [
110                'HttpRequestFactory',
111                CampaignsCentralUserLookup::SERVICE_NAME,
112                ParticipantsStore::SERVICE_NAME
113            ]
114        ];
115    }
116
117    /**
118     * Public version of getRegistry used in tests.
119     * @return array[]
120     * @codeCoverageIgnore
121     */
122    public function getRegistryForTesting(): array {
123        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
124            throw new RuntimeException( 'This method should only be used in tests' );
125        }
126        return $this->getRegistry();
127    }
128
129    /**
130     * Allows changing the internal registry in tests
131     * @param array $registry
132     * @codeCoverageIgnore
133     */
134    public function setRegistryForTesting( array $registry ): void {
135        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
136            throw new RuntimeException( 'This method should only be used in tests' );
137        }
138        $this->registryForTests = $registry;
139    }
140
141    /**
142     * Returns a TrackingTool subclass for a tool specified by its DB ID.
143     *
144     * @param int $dbID
145     * @return TrackingTool
146     * @throws ToolNotFoundException
147     */
148    public function newFromDBID( int $dbID ): TrackingTool {
149        foreach ( $this->getRegistry() as $entry ) {
150            if ( $entry['db-id'] === $dbID ) {
151                return $this->newFromRegistryEntry( $entry );
152            }
153        }
154        throw new ToolNotFoundException( "No tool with DB ID $dbID" );
155    }
156
157    /**
158     * Returns data about known tracking tools that can be used to build the edit registration form. This is a subset
159     * of the internal registry, only including the 'display-name-msg' and 'user-id' keys. See documentation of
160     * getRegistry() for their purpose.
161     *
162     * @return array[]
163     * @phan-return list<array{display-name-msg:string,user-id:string}>
164     */
165    public function getDataForForm(): array {
166        $ret = [];
167        foreach ( $this->getRegistry() as $entry ) {
168            $ret[] = array_intersect_key( $entry, [ 'display-name-msg' => true, 'user-id' => true ] );
169        }
170        return $ret;
171    }
172
173    /**
174     * Returns a TrackingTool subclass for a tool specified by its user identifier.
175     *
176     * @param string $userIdentifier
177     * @return TrackingTool
178     * @throws ToolNotFoundException
179     */
180    public function newFromUserIdentifier( string $userIdentifier ): TrackingTool {
181        foreach ( $this->getRegistry() as $entry ) {
182            if ( $entry['user-id'] === $userIdentifier ) {
183                return $this->newFromRegistryEntry( $entry );
184            }
185        }
186        throw new ToolNotFoundException( "No tool with user ID $userIdentifier" );
187    }
188
189    /**
190     * @param array $entry
191     * @phpcs:ignore Generic.Files.LineLength
192     * @phan-param array{display-name-msg:string,base-url:string,class:class-string,db-id:int,user-id:string,extra:array} $entry
193     * @return TrackingTool
194     * @suppress PhanTypeInvalidCallableArraySize https://github.com/phan/phan/issues/1648
195     */
196    private function newFromRegistryEntry( array $entry ): TrackingTool {
197        $class = $entry['class'];
198        return $this->objectFactory->createObject( [
199            'class' => $class,
200            'args' => [ $entry['db-id'], $entry['base-url'], $entry['extra'] ],
201            'services' => $entry['services'] ?? []
202        ] );
203    }
204
205    /**
206     * Returns information about a tracking tool association that may be used for presentational purposes.
207     *
208     * @param int $dbID
209     * @param string $toolEventID
210     * @return array
211     * @phan-return array{user-id:string,display-name-msg:string,tool-event-url:string}
212     */
213    public function getUserInfo( int $dbID, string $toolEventID ): array {
214        foreach ( $this->getRegistry() as $entry ) {
215            if ( $entry['db-id'] === $dbID ) {
216                /**
217                 * @var TrackingTool $className Note that this is actually a string, but annotating it like this lets
218                 * PHPStorm autocomplete the methods and find their usages.
219                 */
220                $className = $entry['class'];
221                return [
222                    'user-id' => $entry['user-id'],
223                    'display-name-msg' => $entry['display-name-msg'],
224                    'tool-event-url' => $className::buildToolEventURL( $entry['base-url'], $toolEventID ),
225                ];
226            }
227        }
228        throw new ToolNotFoundException( "No tool with DB ID $dbID" );
229    }
230
231    /**
232     * @param string $userID
233     * @param string $toolEventURL
234     * @return string
235     * @throws InvalidToolURLException
236     */
237    public function getToolEventIDFromURL( string $userID, string $toolEventURL ): string {
238        foreach ( $this->getRegistry() as $entry ) {
239            if ( $entry['user-id'] === $userID ) {
240                /**
241                 * @var TrackingTool $className Note that this is actually a string, but annotating it like this lets
242                 * PHPStorm autocomplete the methods and find their usages.
243                 */
244                $className = $entry['class'];
245                return $className::extractEventIDFromURL( $entry['base-url'], $toolEventURL );
246            }
247        }
248        throw new ToolNotFoundException( "No tool with user ID $userID" );
249    }
250}