Now, I know this question has been asked a lot, but I searched and searched but I just can't figure it out and I've been stuck for hours now. I'm really sorry if it turnes out to be a really dumb mistake (It probably will).
So, I have my Controller which instatiates the editAction() function when a button on my website is pressed. It checks if the request is a _POST request, passes on the data and checks if the input given is valid, all this works fine.
I then try to access a function in my Manager class. And that's where the error is happening and my website spits out:
"Call to a member function updateJob() on null".
Now, PhpStorm is not saying there's an error or a warning, it recognizes the jobManager class and I've checked the namespaces and class names, but all are correct. The variables are also defined correctly, as far as I can see. I'd be really thankful if someone could tell me what I am doing wrong. The code is below.
current state after adding $jobManager to __construct:
class IndexController extends AbstractActionController
{
/**
* Entity manager.
* #var EntityManager
*/
private $entityManager;
/**
* Post manager.
* #var JobManager
*/
private $jobManager;
public function __construct($entityManager, $jobManager)
{
$this->entityManager = $entityManager;
/***
* Edit from comment advice:
* I have added this line to my __construct
* But this does not solve the issue.
***/
$this->jobManager = $jobManager;
}
public function indexAction()
{
// Get recent jobs
$jobs = $this->entityManager->getRepository(Jobs::class)
->findBy(['status'=>Jobs::STATUS_READY]
);
// Render the view template
return new ViewModel([
'jobs' => $jobs
]);
}
public function editAction()
{
// Create the form.
$form = new JobForm();
// Get post ID.
$jobId = $this->params()->fromRoute('id', -1);
// Find existing job in the database.
$jobs = $this->entityManager->getRepository(Jobs::class)
->findOneById($jobId);
if ($jobs == null) {
$this->getResponse()->setStatusCode(404);
return;
}
// Check whether this job is a POST request.
if ($this->getRequest()->isPost()) {
// Get POST data.
$data = $this->params()->fromPost();
// Fill form with data.
$form->setData($data);
if ($form->isValid()) {
// Get validated form data.
$data = $form->getData();
// Use job manager service to add new post to database.
$this->jobManager->updateJob( $jobs, $data);
// Redirect the user to "backups" page.
return $this->redirect()->toRoute('backups');
}
} else {
$data = [
'id' => $jobs->getId(),
'jobName' => $jobs->getJobName(),
'status' => $jobs->getStatus(),
'vmId' => $jobs->getVmId(),
'targetfolderPrefix' => $jobs->getTargetFolderPrefix(),
'numberOfBackups' => $jobs->getNumberOfBackups(),
'lastBackupUsed' => $jobs->getLastBackupUsed(),
'repeat' => $jobs->getRepeat(),
'scheduleRunAtMinute' => $jobs->getScheduleRunAtMinute(),
'scheduleRunAtHour' => $jobs->getScheduleRunAtHour(),
'scheduleRunAtDOW' => $jobs->getScheduleRunAtDOW(),
'hostId' => $jobs->getHostId()
];
$form->setData($data);
}
// Render the view template.
return new ViewModel([
'form' => $form,
'jobs' => $jobs
]);
}
}
What is wrong
$this->jobManager->updateJob( $jobs, $data);
You are telling PHP:
"In $this class, look in the jobManager object and run the method updateJob with these variables.... "
But in $this class you have written:
/**
* Post manager.
* #var JobManager
*/
private $jobManager;
But you have nowhere set jobManager to be anything. You have no setter function in the class as well as no other function setting what a jobManager variable actually is... so jobManager can never be anything.
So what you're in effect doing is saying to PHP
"In $this class, look in the jobManager empty null-space and run the method updateJob with these variables..."
This is clearly not going to end well.
How to fix it
You need to set what jobManager is before you can use it, as referenced by Xatenev. Typically when you construct the class or using a Setter method, if preferred.
ONE:
public function __construct(EntityManager $entityManager, JobManager $jobManagerVar)
{
$this->entityManager = $entityManager;
$this->jobManager = $jobManagerVar;
}
Alternatively - if ->jobManager method needs to be defined after the object IndexController is created; then you need to use a Setter class (because the jobManager var is *private*.
Thus TWO:
class IndexController extends AbstractActionController
{
...
public function setJobManager($jobManagerVar){
$this->jobManager = $jobManagerVar
}
...
}
And then when you instantiate the IndexController you can do:
// ONE from above:
$theClass = new IndexController($entity,$jobManager);
or
// TWO from above
$theClass = new IndexController($entity);
...
$theClass->setJobManager($jobManger);
There are various other nuances as to methods of setting values in classes, I'm not going to go over them all, it will depend on what's going on in your wider project.
Related
Have been trying to learn how to implement Services because they get Triggered by a Listener. Have been doing a serious lot of reading the last few days to get it to work, but have been finding it difficult. Thus I'm thinking my understanding of the order of things might be flawed.
The use case I'm trying to get to work is the following:
Just before an Address Entity (with Doctrine, but that's not
important) gets saved (flushed), a Service must be triggered to check
if the Coordinates for the Address are set, and if not, create and
fill a new Coordinates Entity and link it to the Address. The
Coordinates are to be gotten from Google Maps Geocoding API.
Will show below what and how I'm understanding things in the hope I make myself clear. Will do it in steps to show added code in between and tell you what does and doesn't work, as far as I know.
Now, my understanding of all of the information I've gotten the last few days is this:
A Listener has to be registered with ZF2's ServiceManager. The listener "attaches" certain conditions to the (Shared)EventManager. An EventManager is unique to an object, but the SharedEventManager is 'global' in the application.
In the Address module's Module.php class I've added the following function:
/**
* #param EventInterface $e
*/
public function onBootstrap(EventInterface $e)
{
$eventManager = $e->getTarget()->getEventManager();
$eventManager->attach(new AddressListener());
}
This gets works, the AddressListener gets triggered.
The AddressListener is as follows:
use Address\Entity\Address;
use Address\Service\GoogleCoordinatesService;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;
use Zend\Stdlib\CallbackHandler;
class AddressListener implements ListenerAggregateInterface
{
/**
* #var CallbackHandler
*/
protected $listeners;
/**
* #param EventManagerInterface $events
*/
public function attach(EventManagerInterface $events)
{
$sharedEvents = $events->getSharedManager();
// Not sure how and what order params should be. The ListenerAggregateInterface docblocks didn't help me a lot with that either, as did the official ZF2 docs. So, been trying a few things...
$this->listeners[] = $sharedEvents->attach(GoogleCoordinatesService::class, 'getCoordinates', [$this, 'addressCreated'], 100);
$this->listeners[] = $sharedEvents->attach(Address::class, 'entity.preFlush', [GoogleCoordinatesService::class, 'getCoordinates'], 100);
}
/**
* #param EventManagerInterface $events
*/
public function detach(EventManagerInterface $events)
{
foreach ($this->listeners as $index => $listener) {
if ($events->detach($listener)) {
unset($this->listeners[$index]);
}
}
}
public function addressCreated()
{
$foo = 'bar'; // This line is here to as debug break. Line is never used...
}
}
I was expecting a Listener to work as a sort-of stepping stone point to where things get triggered, based on the ->attach() functions in the function attach(...){}. However, this does not seem to work, as nothing gets triggered. Not the addressCreated() function and not the getCoordinates function in the GoogleCoordinatesService.
The code above is supposed to trigger the GoogleCoordinatesService function getCoordinates. The Service has a few requirements though, such as the presence of the EntityManager of Doctrine, the Address Entity it concerns and configuration.
To that effect, I've created the following configuration.
File google.config.php (gets loaded, checked that)
return [
'google' => [
'services' => [
'maps' => [
'services' => [
'geocoding' => [
'api_url' => 'https://maps.googleapis.com/maps/api/geocode/json?',
'api_key' => '',
'url_params' => [
'required' => [
'address',
],
'optional' => [
'key'
],
],
],
],
],
],
],
];
And in module.config.php I've registered the Service with a Factory
'service_manager' => [
'factories' => [
GoogleCoordinatesService::class => GoogleCoordinatesServiceFactory::class,
],
],
The Factory is pretty standard ZF2 stuff, but to paint a complete picture, here is the GoogleCoordinatesServiceFactory.php class. (Removed comments/typehints/etc)
class GoogleCoordinatesServiceFactory implements FactoryInterface
{
public function createService(ServiceLocatorInterface $serviceLocator, $options = [])
{
$serviceManager = $serviceLocator->getServiceLocator();
$entityManager = $serviceManager->get(EntityManager::class);
$config = $serviceManager->get('Config');
if (isset($options) && isset($options['address'])) {
$address = $options['address'];
} else {
throw new InvalidArgumentException('Must provide an Address Entity.');
}
return new GoogleCoordinatesService(
$entityManager,
$config,
$address
);
}
}
Below is the GoogleCoordinatesService class. However, nothing ever gets triggered to executed in there. As it doesn't even gets called I'm sure the problem lies in the code above, but cannot find out why. From what I've read and tried, I'm expecting that the class itself should get called, via the Factory and the getCoordinates function should be triggered.
So, the class. I've removed a bunch of standard getters/setters, comments, docblocks and typehints to make it shorter.
class GoogleCoordinatesService implements EventManagerAwareInterface
{
protected $eventManager;
protected $entityManager;
protected $config;
protected $address;
/**
* GoogleCoordinatesServices constructor.
* #param EntityManager $entityManager
* #param Config|array $config
* #param Address $address
* #throws InvalidParamNameException
*/
public function __construct(EntityManager $entityManager, $config, Address $address)
{
$this->config = $config;
$this->address = $address;
$this->entityManager = $entityManager;
}
public function getCoordinates()
{
$url = $this->getConfig()['api_url'] . 'address=' . $this->urlFormatAddress($this->getAddress());
$response = json_decode(file_get_contents($url), true);
if ($response['status'] == 'OK') {
$coordinates = new Coordinates();
$coordinates
->setLatitude($response['results'][0]['geometry']['location']['lat'])
->setLongitude($response['results'][0]['geometry']['location']['lng']);
$this->getEntityManager()->persist($coordinates);
$this->getAddress()->setCoordinates($coordinates);
$this->getEntityManager()->persist($this->getAddress());
$this->getEntityManager()->flush();
$this->getEventManager()->trigger(
'addressReceivedCoordinates',
null,
['address' => $this->getAddress()]
);
} else {
// TODO throw/set error/status
}
}
public function urlFormatAddress(Address $address)
{
$string = // format the address into a string
return urlencode($string);
}
public function getEventManager()
{
if ($this->eventManager === null) {
$this->setEventManager(new EventManager());
}
return $this->eventManager;
}
public function setEventManager(EventManagerInterface $eventManager)
{
$eventManager->addIdentifiers([
__CLASS__,
get_called_class()
]);
$this->eventManager = $eventManager;
return $this;
}
// Getters/Setters for EntityManager, Config and Address
}
So, that's the setup to handle it when a certain event gets triggered. Now it should, of course, get triggered. For this use case I've setup a trigger in the AbstractActionController of my own (extends ZF2's AbstractActionController). Doing that like so:
if ($form->isValid()) {
$entity = $form->getObject();
$this->getEntityManager()->persist($entity);
try {
// Trigger preFlush event, pass along Entity. Other Listeners can subscribe to this name.
$this->getEventManager()->trigger(
'entity.preFlush',
null,
[get_class($entity) => $entity] // key = "Address\Entity\Address" for use case
);
$this->getEntityManager()->flush();
} catch (\Exception $e) {
// Error thrown
}
// Success stuff, like a trigger "entity.postFlush"
}
So yea. At the moment at a bit of a loss on how to get it working.
Any help would be very much appreciated and would love explanations as to the "why" of it is that a solution works. That would really help me out making more of these services :)
Been at it for a while, but have managed to figure out why it was not working. I was attaching Listeners to EventManagers, but should have been attaching them to the SharedEventManager. This is because I have the triggers (in this instance) in the AbstractActionController, thus they all create their own EventManager (as they're unique) when instantiated.
Has been a tough few days wrapping my head around it all, but this article helped me out most, or perhaps it just made things click with my original research in the question and subsequent trial & error + debugging.
Below the code as it is now, in working order. I'll try to explain along as the code comes as to how I understand that it works. If I get it wrong at some point I hope someone corrects me.
First up, we need a Listener, a class which registers components and events to "listen" for them to trigger. (They listen for certain (named) objects to trigger certain events)
The realization quickly came that pretty much every Listener would need the $listeners = []; and the detach(EventManagerInterface $events){...} function. So I created an AbstractListener class.
namespace Mvc\Listener;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;
/**
* Class AbstractListener
* #package Mvc\Listener
*/
abstract class AbstractListener implements ListenerAggregateInterface
{
/**
* #var array
*/
protected $listeners = [];
/**
* #param EventManagerInterface $events
*/
public function detach(EventManagerInterface $events)
{
foreach ($this->listeners as $index => $listener) {
if ($events->detach($listener)) {
unset($this->listeners[$index]);
}
}
}
}
After the above mentioned realization about having to use the SharedEventManager and with the AbstractListener created, the AddressListener class has ended up like so.
namespace Address\Listener;
use Address\Event\AddressEvent;
use Admin\Address\Controller\AddressController;
use Mvc\Listener\AbstractListener;
use Zend\EventManager\EventManagerInterface;
/**
* Class AddressListener
* #package Address\Listener
*/
class AddressListener extends AbstractListener
{
/**
* #param EventManagerInterface $events
*/
public function attach(EventManagerInterface $events)
{
$sharedManager = $events->getSharedManager();
$sharedManager->attach(AddressController::class, 'entity.postPersist', [new AddressEvent(), 'addCoordinatesToAddress']);
}
}
The main difference with attaching events to EventManager versus the SharedEventManager is that the latter listens for a specific class to emit a trigger. In this instance it will listen for the AddressController::class to emit the trigger entity.postPersist. Upon "hearing" that it's triggered it will call a callback function. In this case that is registered with this array parameter: [new AddressEvent(), 'addCoordinatesToAddress'], meaning that it will use the class AddressEvent and the function addCoordinatesToAddress.
To test if this works, and if you're working along with this answer, you can create the trigger in your own Controller. I've been working in the addAction of the AbstractActionController, which gets called by the addAction of the AddressController. Below the trigger for the Listener above:
if ($form->isValid()) {
$entity = $form->getObject();
$this->getEntityManager()->persist($entity);
$this->getEventManager()->trigger(
'entity.postPersist',
$this,
[get_class($entity) => $entity]
);
try {
$this->getEntityManager()->flush();
} catch (\Exception $e) {
// Error stuff
}
// Remainder of function
}
The ->trigger() function in the above code shows the usage of the following parameters:
'entity.postPersist' - This is the event name
$this - This is the "component" or object the event is called for. In this instance it will be Address\Controller\AddressController
[get_class($entity) => $entity] - These are parameters to send along with this Event object. It will cause you to have available $event->getParams()[Address::class] which will have the $entity value.
The first two parameters will trigger the Listener in the SharedEventManager. To test if it all works, it's possible to modify the Listener's attach function.
Modify it to this and create a function within the the Listener so you can see it working:
public function attach(EventManagerInterface $events)
{
$sharedManager = $events->getSharedManager();
$sharedManager->attach(AddressController::class, 'entity.postPersist', [$this, 'test']);
}
public function test(Event $event)
{
var_dump($event);
exit;
}
Lastly, to make sure that the above actually works, the Listener must be registered with the EventManager. This happens in the onBootstrap function in the Module.php file of the module (Address in this case). Register like below.
public function onBootstrap(MvcEvent $e)
{
$eventManager = $e->getApplication()->getEventManager();
$eventManager->attach(new AddressListener());
}
If you debug the code of the addAction in the AbstractActionController, see it pass the trigger and next you're in the test function, then your Listener works.
The above code also implies that the AddressListener class can be used to attach more than one listener. So you could also register stuff for entity.prePersist, entity.preFlush, entity.postFlush and anything else you can think of.
Next up, revert the Listener back to what it was at the beginning (revert the attach function and remove the test function).
I also noticed that pretty much every Event handling class would need to be able to set and get the EventManager. Thus, for this I've created an AbstractEvent class, like below.
namespace Mvc\Event;
use Zend\EventManager\EventManager;
use Zend\EventManager\EventManagerAwareInterface;
use Zend\EventManager\EventManagerInterface;
abstract class AbstractEvent implements EventManagerAwareInterface
{
/**
* #var EventManagerInterface
*/
protected $events;
/**
* #param EventManagerInterface $events
*/
public function setEventManager(EventManagerInterface $events)
{
$events->setIdentifiers([
__CLASS__,
get_class($this)
]);
$this->events = $events;
}
/**
* #return EventManagerInterface
*/
public function getEventManager()
{
if (!$this->events) {
$this->setEventManager(new EventManager());
}
return $this->events;
}
}
To be honest, I'm not quite sure why we set 2 identifiers in the setEventManager function. But suffice to say that it's used to register callbacks for Events. (this could use more/detailed explanation if someone feels so inclined as to provide it)
In the AddressListener we're trying to call the addCoordinatesToAddress function of the AddressEvent class. So we're going to have to create that, I did it like below.
namespace Address\Event;
use Address\Entity\Address;
use Address\Service\GoogleGeocodingService;
use Country\Entity\Coordinates;
use Mvc\Event\AbstractEvent;
use Zend\EventManager\Event;
use Zend\EventManager\Exception\InvalidArgumentException;
class AddressEvent extends AbstractEvent
{
public function addCoordinatesToAddress(Event $event)
{
$params = $event->getParams();
if (!isset($params[Address::class]) || !$params[Address::class] instanceof Address) {
throw new InvalidArgumentException(__CLASS__ . ' was expecting param with key ' . Address::class . ' and value instance of same Entity.');
}
/** #var Address $address */
$address = $params[Address::class];
if (!$address->getCoordinates() instanceof Coordinates) {
/** #var GoogleGeocodingService $geocodingService */
$geocodingService = $event->getTarget()->getEvent()->getApplication()->getServiceManager()->get(GoogleGeocodingService::class);
$geocodingService->addCoordinatesToAddress($address);
}
$params = compact('address');
$this->getEventManager()->trigger(__FUNCTION__, $this, $params);
}
}
In the above you can see that first we check if the parameter we expect has been passed along with the Event $event parameter. We know what we should expect and what name the key should have, so we check explicitly.
Next we check if the received Address Entity object already has a Coordinates object associated with it, if it doesn't, we call a Service to make it happen.
After the if() statement has run, we fire another trigger. We pass along this Event object and the parameters. This last step is not required, but can be handy if you wish to chain events.
In the question I mentioned a use case. The above code enables the Service (GoogleGeocodingService) to get passed the it's requirements and combined with the configuration for the Factory, it gets created via Zend Magic with the ServiceManager.
The code to add a new Coordinates object to the existing Address object was not modified, so I won't make it part of the answer, you can find that in the question.
I have a big form with time, date, select and EntityType fields in it. The form is not linked to an entity, but it do contain fields from other entities. In other words I have no data_class in the OptionsResolver of the FormType.
Here is my formtype: (showing just one field for simplicity)
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('company', EntityType::class, array(
'placeholder' => 'Companies',
'class' => 'AppBundle:Company',
'choice_label' => 'name',
'multiple' => true,
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('c')->orderBy('c.name', 'ASC');
},
'required' => false
))
//... much more fields
}
public function configureOptions(OptionsResolver $resolver)
{
// This form is not linked to an Entity
// Therefore no `data_class`
}
In the controller I can backup the data of a form. Called a FormState. I save a FormState to the database as follows:
namespace AppBundle\Controller;
class ReportController extends Controller
{
/** // ... */
public function listAction(Request $request)
{
$form = $this->createForm(ReportType::class);
$form->handleRequest($request);
// Save the form to a FormState
if ($form->isSubmitted() && $form->getClickedButton()->getName() == 'saveFormState') {
$formStateManager = $this->get('app.manager.form_state');
// Get form data that we want to save
$data = $form->getData();
$name = 'Stackoverflow example'; // give it a name
$formState = $formStateManager->createNewFormState(); // Create new FormState Entity object
$formState->setName( $name );
$formState->setData( $data );
$formStateManager->save( $formState );
}
// ...
}
}
All this above works perfect. But now the tricky part, I have to set the backupdata back to the form. The user can select a previous form state from a list of FormStates. And then hit the load button.
The data attribute of a FormState object I try to load into the form is just a $form->getData() result. Thus a normal array without objects. Maybe that is the problem, but I can't get it to work whatsoever.
That data array is what I trying to load into the form so it takes its values over. I tried it via $form->setData(), or by creating a new form with $this->createForm(ReportType::class, $data). Both fail with the same error message: Entities passed to the choice field must be managed. Maybe persist them in the entity manager?.
I have tried two ways of adding the data:
First try, in controller:
namespace AppBundle\Controller;
class ReportController extends Controller
{
/** // ... */
public function listAction(Request $request)
{
if ($form->isSubmitted() && $form->getClickedButton()->getName() == 'loadFormState') {
// ...
$form = $this->createForm(ReportType::class, $formState->getData()); // <-- throws the error
$form->submit($formState->getData());
}
}
}
Second try, via FormEvent subscriber:
When I do it via a FormEvent subscriber, like below, I get the same error. Here is my code:
namespace AppBundle\Form\EventListener;
class ReportFieldsSubscriber implements EventSubscriberInterface
{
// ...
public static function getSubscribedEvents()
{
return array(
FormEvents::PRE_SUBMIT => array(
array('loadFormState'),
),
);
}
/**
* Load selected FormState in form
* #param FormEvent $event
*/
public function loadFormState(FormEvent $event)
{
$form = $event->getForm();
//...
// Choosen FormState
$formState = $formStateManager->findOneById($formStateId);
$formStateData = $formState->getData();
$form->setData($formStateData); // <-- Set data, trows error
// ...
}
}
As I said both solutions throwing the error: Entities passed to the choice field must be managed. Maybe persist them in the entity manager?
What is the preferred solution in this case? I can create a dummy Entity, and fix this problem, but it feels a bit ugly and not the way to go.
After days of fiddling I found the solution.
My struggles where not how to save form data, as I showed in the question. However, I made a mistake by saving view data to the database and trying to load that later. Have a good read here for what I mean: https://symfony.com/doc/current/form/events.html#registering-event-listeners-or-event-subscribers
So, I also rewrite the saving of a form state to be in the form subscriber. Note: remember my form has no data_class in the OptionsResolver settings of the FormType. So there is no auto datamapping and such.
In my initial question, you can see I have multiple submit buttons, and in the controller you can check which one was clicked easily. Because in the controller you have access to the getClickedButton() function. But not in the form subscriber. So I found a way to get the clicked button in the EventSubscriberInterface of the form:
class ReportFieldsSubscriber implements EventSubscriberInterface
{
/**
* Find which button was clicked
* #param $data
* #return string
*/
public function getClickedButton($data)
{
$requestParams = serialize($data);
if (strpos($requestParams, 'saveFormState') !== false) {
return 'saveFormState';
}
if (strpos($requestParams, 'loadFormState') !== false) {
return 'loadFormState';
}
if (strpos($requestParams, 'deleteFormState') !== false) {
return 'deleteFormState';
}
return "";
}
}
Below you find the actual solution how I saved and loadedthe form the right way. I hook them up in the FormEvent::PRE_SUBMIT stage. At that stage you can save and load Request data into the FormEvent. Perfect.
Saving and loading/prepopulating the form data via FormEvent::PRE_SUBMIT:
class ReportFieldsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
FormEvents::PRE_SUBMIT => array(
array('saveFormState'),
array('loadFormState'),
),
);
}
/**
* Save form data
* #param FormEvent $event
*/
public function saveFormState(FormEvent $event)
{
$data = $event->getData();
if ($this->getClickedButton($data) == 'saveFormState') {
$formStateManager = $this->formStateManager;
// ...
$formState = $formStateManager->createNewFormState();
// ...
$formState->setData($data);
$formStateManager->save($formState);
}
}
/**
* Load choosen form data
* #param FormEvent $event
*/
public function loadFormState(FormEvent $event)
{
$data = $event->getData();
if ($this->getClickedButton($data) == 'loadFormState') {
$formStateId = $data['formState']['formStates'];
if (is_numeric($formStateId)) {
$formStateManager = $this->formStateManager;
$formState = $formStateManager->findOneById(intval($formStateId));
// Choosen FormState data
$formStateData = $formState->getData();
$event->setData($formStateData); // <-- Call setData() on the FormEvent, not on the form itself
}
}
}
}
I am pretty sure this solution will save someone days of coding!
Alright so I'm pretty new to both unit testing, mockery and laravel. I'm trying to unit test my resource controller, but I'm stuck at the update function. Not sure if I'm doing something wrong or just thinking wrong.
Here's my controller:
class BooksController extends \BaseController {
// Change template.
protected $books;
public function __construct(Book $books)
{
$this->books = $books;
}
/**
* Store a newly created book in storage.
*
* #return Response
*/
public function store()
{
$data = Input::except(array('_token'));
$validator = Validator::make($data, Book::$rules);
if($validator->fails())
{
return Redirect::route('books.create')
->withErrors($validator->errors())
->withInput();
}
$this->books->create($data);
return Redirect::route('books.index');
}
/**
* Update the specified book in storage.
*
* #param int $id
* #return Response
*/
public function update($id)
{
$book = $this->books->findOrFail($id);
$data = Input::except(array('_token', '_method'));
$validator = Validator::make($data, Book::$rules);
if($validator->fails())
{
// Change template.
return Redirect::route('books.edit', $id)->withErrors($validator->errors())->withInput();
}
$book->update($data);
return Redirect::route('books.show', $id);
}
}
And here are my tests:
public function testStore()
{
// Add title to Input to pass validation.
Input::replace(array('title' => 'asd', 'content' => ''));
// Use the mock object to avoid database hitting.
$this->mock
->shouldReceive('create')
->once()
->andReturn('truthy');
// Pass along input to the store function.
$this->action('POST', 'books.store', null, Input::all());
$this->assertRedirectedTo('books');
}
public function testUpdate()
{
Input::replace(array('title' => 'Test', 'content' => 'new content'));
$this->mock->shouldReceive('findOrFail')->once()->andReturn(new Book());
$this->mock->shouldReceive('update')->once()->andReturn('truthy');
$this->action('PUT', 'books.update', 1, Input::all());
$this->assertRedirectedTo('books/1');
}
The issue is, when I do it like this, I get Mockery\Exception\InvalidCountException: Method update() from Mockery_0_Book should be called exactly 1 times but called 0 times. because of the $book->update($data) in my controller. If I were to change it to $this->books->update($data), it would be mocked properly and the database wouldn't be touched, but it would update all my records when using the function from frontend.
I guess I simply just want to know how to mock the $book-object properly.
Am I clear enough? Let me know otherwise. Thanks!
Try mocking out the findOrFail method not to return a new Book, but to return a mock object instead that has an update method on it.
$mockBook = Mockery::mock('Book[update]');
$mockBook->shouldReceive('update')->once();
$this->mock->shouldReceive('findOrFail')->once()->andReturn($mockBook);
If your database is a managed dependency and you use mock in your test it causes brittle tests.
Don't mock manage dependencies.
Manage dependencies: dependencies that you have full control over.
I appear to be having issues with my spec tests when it comes to stubs that are calling other methods.
I've been following Laracasts 'hexagonal' approach for my controller to ensure it is only responsible for the HTTP layer.
Controller
<?php
use Apes\Utilities\Connect;
use \OAuth;
class FacebookConnectController extends \BaseController {
/**
* #var $connect
*/
protected $connect;
/**
* Instantiates $connect
*
* #param $connect
*/
function __construct()
{
$this->connect = new Connect($this, OAuth::consumer('Facebook'));
}
/**
* Login user with facebook
*
* #return void
*/
public function initialise() {
// TODO: Actually probably not needed as we'll control
// whether this controller is called via a filter or similar
if(Auth::user()) return Redirect::to('/');
return $this->connect->loginOrCreate(Input::all());
}
/**
* User authenticated, return to main game view
* #return Response
*/
public function facebookConnectSucceeds()
{
return Redirect::to('/');
}
}
So when the route is initialised I construct a new Connect instance and I pass an instance of $this class to my Connect class (to act as a listener) and call the loginOrCreate method.
Apes\Utilities\Connect
<?php
namespace Apes\Utilities;
use Apes\Creators\Account;
use Illuminate\Database\Eloquent\Model;
use \User;
use \Auth;
use \Carbon\Carbon as Carbon;
class Connect
{
/**
* #var $facebookConnect
*/
protected $facebookConnect;
/**
* #var $account
*/
protected $account;
/**
* #var $facebookAuthorizationUri
*/
// protected $facebookAuthorizationUri;
/**
* #var $listener
*/
protected $listener;
public function __construct($listener, $facebookConnect)
{
$this->listener = $listener;
$this->facebookConnect = $facebookConnect;
$this->account = new Account();
}
public function loginOrCreate($input)
{
// Not the focus of this test
if(!isset($input['code'])){
return $this->handleOtherRequests($input);
}
// Trying to stub this method is my main issue
$facebookUserData = $this->getFacebookUserData($input['code']);
$user = User::where('email', '=', $facebookUserData->email)->first();
if(!$user){
// Not the focus of this test
$user = $this->createAccount($facebookUserData);
}
Auth::login($user, true);
// I want to test that this method is called
return $this->listener->facebookConnectSucceeds();
}
public function getFacebookUserData($code)
{
// I can't seem to stub this method because it's making another method call
$token = $this->facebookConnect->requestAccessToken($code);
return (object) json_decode($this->facebookConnect->request( '/me' ), true);
}
// Various other methods not relevant to this question
I've tried to trim this down to focus on the methods under test and my understanding thus far as to what is going wrong.
Connect Spec
<?php
namespace spec\Apes\Utilities;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use \Illuminate\Routing\Controllers\Controller;
use \OAuth;
use \Apes\Creators\Account;
class ConnectSpec extends ObjectBehavior
{
function let(\FacebookConnectController $listener, \OAuth $facebookConnect, \Apes\Creators\Account $account)
{
$this->beConstructedWith($listener, $facebookConnect, $account);
}
function it_should_login_the_user($listener)
{
$input = ['code' => 'afacebooktoken'];
$returnCurrentUser = (object) [
'email' => 'existinguser#domain.tld',
];
$this->getFacebookUserData($input)->willReturn($returnCurrentUser);
$listener->facebookConnectSucceeds()->shouldBeCalled();
$this->loginOrCreate($input);
}
So here's the spec that I'm having issues with. First I pretend that I've got a facebook token already. Then, where things are failing, is that I need to fudge that the getFacebookUserData method will return a sample user that exists in my users table.
However when I run the test I get:
Apes/Utilities/Connect
37 ! it should login the user
method `Double\Artdarek\OAuth\Facade\OAuth\P13::requestAccessToken()` not found.
I had hoped that 'willReturn' would just ignore whatever was happening in the getFacebookUserData method as I'm testing that separately, but it seems not.
Any recommendations on what I should be doing?
Do I need to pull all of the OAuth class methods into their own class or something? It seems strange to me that I might need to do that considering OAuth is already its own class. Is there some way to stub the method in getFacebookUserData?
Update 1
So I tried stubbing the method that's being called inside getFacebookUserData and my updated spec looks like this:
function it_should_login_the_user($listener, $facebookConnect)
{
$returnCurrentUser = (object) [
'email' => 'existinguser#domain.tld',
];
$input = ['code' => 'afacebooktoken'];
// Try stubbing any methods that are called in getFacebookUserData
$facebookConnect->requestAccessToken($input)->willReturn('alongstring');
$facebookConnect->request($input)->willReturn($returnCurrentUser);
$this->getFacebookUserData($input)->willReturn($returnCurrentUser);
$listener->facebookConnectSucceeds()->shouldBeCalled();
$this->loginOrCreate($input);
}
The spec still fails but the error has changed:
Apes/Utilities/Connect
37 ! it should login the user
method `Double\Artdarek\OAuth\Facade\OAuth\P13::requestAccessToken()` is not defined.
Interestingly if I place these new stubs after the $this->getFacebookUserData stub then the error is 'not found' instead of 'not defined'. Clearly I don't fully understand the inner workings at hand :D
Not everything, called methods in your dependencies have to be mocked, because they will in fact be called while testing your classes:
...
$facebookConnect->requestAccessToken($input)->willReturn(<whatever it should return>);
$this->getFacebookUserData($input)->willReturn($returnCurrentUser);
...
If you don't mock them, phpspec will raise a not found.
I'm not familiar with the classes involved but that error implies there is not method Oauth:: requestAccessToken().
Prophecy will not let you stub non-existent methods.
I have a laravel model which uses ardent/eloquent. I am trying to set up tests for the controller in particular, storing a new model that uses the ardent model.
The method works in the app but I'm having trouble with my tests
I'm having problems working out how to mock the calls this method makes.
My controllers set up and the method in question is this one:
use golfmanager\service\creator\TicketCreatorInterface;
//controller manages the ticket books
class BooksController extends BaseController {
/**
* Book Repository
*
* #var Book
*/
protected $book;
protected $ticket;
public function __construct(Book $book, TicketCreatorInterface $ticket)
{
$this->book = $book;
$this->ticket = $ticket;
}
public function store()
{
$input = Input::all();
$result = $this->book->save();
if ($result) {
//if book created then create tickets
$this->ticket->createTicket($input, $this->book);
return Redirect::route('books.index');
}
return Redirect::route('books.create')
->withInput()
->withArdentErrors()
->with('message', 'There were validation errors.');
}
And the methods used by the interface (TicketCreator):
public function createTicket($input, $book) {
//dd($input);
$counter = $input['start_number'];
while($counter <= $input['end_number']) {
$ticketDetails = array(
'ticketnumber'=>$counter,
'status'=>'unused',
'active'=>1
);
$this->ticket->create($ticketDetails)->save();
$this->ticket->book()->associate($book)->save();
$counter = $counter+1;
}
return $counter;
}
My test is as follows:
use Mockery as m;
use Way\Tests\Factory;
class BooksTest extends TestCase {
public function __construct()
{
$this->mock = m::mock('Ardent', 'Book');
$this->collection = m::mock('Illuminate\Database\Eloquent\Collection')->shouldDeferMissing();
}
public function setUp()
{
parent::setUp();
$this->attributes = Factory::book(['id' => 1, 'assigned_date'=> '20/11/2013']);
$this->app->instance('Book', $this->mock);
}
public function testStore()
{
Input::replace($input = ['start_number'=>1000, 'end_number'=>1010, 'assigned_date'=>'20/11/2013']);
$this->mock->shouldReceive('save')->once()->andReturn(true);
$this->ticket->shouldReceive('createTicket')->once()->with($input, $this->mock)->andReturn(true);
//with($input);
//$this->validate(true);
$this->call('POST', 'books');
$this->assertRedirectedToRoute('books.index');
}
Currently I get an error:
No matching handler found for Book::save()
Is this being thrown because the book model doesnt contain a save method? If it is how do I mock the model correctly. I don't want it to touch the database (although it could if it has to).
Is it the multiple saves in the createTicket method?
Still learning how to set up tests correctly - slowly getting there but not enough knowledge yet.
If I change the name of the method in shouldReceive to say 'store' it still comes up with the save() error message.
Update:
I have isolated part of the problem to the createTicket call. I've changed my testStore test and updated as above.
My error with this current test is: Undefined index: start_number.
If I remove the call to createTicket in the controller method I don't get an error. I tried using Input::replace to replace the input from a form but appears not getting through to my function
How can I simulate a form input in the mocked objects?
Thanks