Last time I left we had a working data access layer, based on Active Record, as well as a scaffolding for editing users. We used TDD to drive the functionality of the application, and I intend to keep doing it this way while developing the functionality of the site itself.
Before we start by building the initial list of tests, let us consider what we want to achieve...
We want to have a forum system, so diplaying the forums, adding and browsing messages is very important, but managing users / forums is not a high priority, we can leave the users management on the scaffolding, and build the scaffolding for forums as well. Here is what we need to do to build the scaffolding for Forum:
[Layout("default"), Rescue("generalerror"), Scaffolding(typeof(Forum))]
public class ForumController : SmartDispatcherController
{
}
And now we can access it at /forum/list.rails
One thing that I forgot to do before is to add the ActiveRecord HttpModule, which goes into the <httpModules> section in <system.web> element, like this:
<add name="active-record"
type="Castle.ActiveRecord.Framework.SessionScopeWebModule, Castle.ActiveRecord" />
Now, let us see what kind of scenarios we want to support:
- The homepage should display a list of all the forums, by name and id, with links to the forum page.
- There should be a text box where I can enter a forum id and go directly to it.
- A forum should display ten root messages, and be pagable.
- Can add a message to forum.
- Can reply to message.
- Messages appear in hierarchy.
When building tests for a MonoRail application, we have two options, we can do normal tests, which involve testing the data sent from the controller to the view, or integration tests, where we assert on the HTML output from the view. Let us start with the first test, asserting that the homepage controller pass the view a list of all forums. We start by building the test data in the test setup. But in order to do this, we need to setup Active Record.
In the previous installment, we already setup Active Record for the MonoRail application, but now we are calling Active Record from the tests, and that is in a different AppDomain.
I decided to do the setup in a test base class, like this:
public class AbstractMRWithARTestCase : AbstractMRTestCase
{
[SetUp]
public override void Initialize()
{
ActiveRecordStarter.ResetInitializationFlag();
IConfigurationSource source = ActiveRecordSectionHandler.Instance;
ActiveRecordStarter.Initialize(System.Reflection.Assembly.Load("Castle.Forums.Model"), source);
ActiveRecordStarter.CreateSchema();
base.Initialize();
}
}
This initialize the framework, and then rebuild the database, so the tests are isolated.
Now, let us see the real setup code:
[SetUp]
public override void Initialize()
{
base.Initialize();
User user = new User("Ayende", "Ayende@example.org");
user.SetPassword("Scott/Tiger");
user.Create();
Forum forum1 = new Forum("My Forum 1", user);
forum1.Create();
Forum forum2 = new Forum("My Forum 2", user);
forum2.Create();
}
Now, after all this, we can start creating the first real test, it goes like this:
[Test]
public void IndexAction_PassAllForumsToView()
{
DoGet("home/index.rails");
AssertPropertyBagContains("forums");
Forum[] forums = (Forum[])Response.PropertyBag["forums"];
Assert.AreEqual(2, forums.Length);
}
What does this do? Well, it asserts that the controller has passed two forums to the view. Running this test now will fail with this erro:
We have a red light! Let us make it green. We can make this test pass using this code:
public void Index()
{
PropertyBag["forums"] = Forum.FindAll();
}
We run the code, and this time it is failing with another error, complaining that Forum is not a serializable type. This is an annoying side affect of the need to run ASP.Net in another AppDomain, everything that goes between the domains has to be serializable. This is an annoying limitation, and we are thinking about ways to fix this, but currently there isn't a good way to do this, since the ASP.Net runtime creates its own AppDomain, and that is hard to change. So, we need to put [Serializable] on Forum, User and Message classes. I usually don't like artificial test requirements, but this isn't an onerous one, and there are valid reasons to want to do this anyway.
After making the model entities serializable, the test pass! Of course, if we go to the page, it doesn't show anything, we need another test, one that would assert that the view is displaying the fourms:
[Test]
public void IndexView_DisplayAllForums()
{
DoGet("home/index.rails");
AssertReplyContains("My Forum 1");
AssertReplyContains("My Forum 2");
}
Run and see it fails... Let us make this work. Go to the view, which is on the Castle.Forums.Web project, "Views\Home\index.boo". We need to output the forums out, so let us do it simple as:
<h2>Forums List</h2>
<ul>
<?brail for forum in forums: ?>
<li>${forum.Id} - ${forum.Name}</li>
<?brail end ?>
</ul>
Run the test again to make sure that it passes, and let us move on. We don't have a list to each forum's page, but this is because it doesn't exists yet. So let us create it. I think that /forum/1/index.rails is a good place to put it, so let us create a ForumControllerTestCase class, for testing the new functionality. In the setup (which I will not show) we merely create a user, a forum and 50 messages (each with 2 reply messages). The root messages has names like Root 1 and Root 30, and their children names like Child 1.3 or Child 30.2, this is just so we can assert on them with ease.
Let us start by writing the test:
[Test]
public void ForumController_PassAllRootMessagesToView()
{
DoGet(string.Format("forum/{0}/index.rails", forumId));
AssertSuccess();
AssertPropertyBagContains("messages");
Message[] msgs = (Message[])Response.PropertyBag["messages"];
Assert.AreEqual(50, msgs.Length);
}
Very similar to what we did before. Notice that forumId is the id of the forum we create previously. Now, to get the to pass we need to do quite a bit.
First, let us add an index method to ForumController:
public void Index(int forumId)
{
Forum forum = Forum.Find(forumId);
PropertyBag["messages"] = Message.FindAll(Expression.Eq("Forum", forum),
Expression.IsNull("RootParent"));
}
Get the forum, and then get all the message for the forum, very simple. Note that this method is called by the MonoRail framework, and it has a parameter. This parameter is parsed by MonoRail from the request parameters (both query string and forms parameters are considered).
Considerring that the URL that we want to use has the format of "/forum/1/index.rails", how can MonoRail understand that it should use the 1 as the parameter to the index? We can tell it so with the use of routing rules. A routing rule is merely a small piece of regex that transfrom one representation of the request URL to another.
You can configure routing using the <monorail>/<routing> element, by adding a rule:
<rule>
<pattern>/forum/(\d+)/(.*)$</pattern>
<replace><![CDATA[ /forum/$2?forumId=$1 ]]></replace>
</rule>
We are simple replacing every reference to /forum/number/page with /forum/page?id=number. Now, MonoRail can translate the "folder" into the correct parameter. We are not done yet, we still need to create a view to show a forum's listing. We need to create a a subdirectory under views "Forum", which will contain all the forum's views. Then, add an empty file called index.boo to it.
Now the test passes, but we have nothing in the view. Also, notice that we aren't doing any paging yet.
Here is a test that assert that the page shows something, and that it is using paging:
[Test]
public void ForumsView_ShowsOnlyFirstRootMessages()
{
DoGet(string.Format("forum/{0}/index.rails", forumId));
AssertReplyContains("Root 1");
AssertReplyContains("Root 9");
AssertReplyDoesNotContain("Root 11");//that should be paged
}
MonoRail has a feature called CachedPagination, which we are about to make use of. It means that we are going to need to change the controller code, and since the pagination is not serializable, so we need to restructure our tests first. Let us start with the code this time:
public void Index(int forumId)
{
string paginationCacheKey = string.Format("forum.{0}.messages", forumId);
DataObtentionDelegate getMessageForForum = delegate
{
Forum forum = Forum.Find(forumId);
return Message.FindAll(Expression.Eq("Forum", forum), Expression.IsNull("RootParent"));
};
IPaginatedPage cachedPagination = PaginationHelper.CreateCachedPagination(paginationCacheKey,10,
getMessageForForum);
PropertyBag["messages"] = cachedPagination;
Flash["messages.TotalItems"] = cachedPagination.TotalItems;
}
What is going on? We create a cache key for the results, this should help reduce the amount of queries that we have in a big site, not really neccecary in our case, but it is so elegant that I can't help it ;-) We pass a delegate to the method, which will be called when there is a cache miss to get the data. Then we put the resulting pagination and the total item count into it. Notice that I don't need to specify which page I was working with, that is the responsability of the PaginationHelper.
I put the total count into the Flash so I could still test that I am getting the results from the controller. Since pagination is not serializble, I can no longer access PropertyBag, it can't pass the AppDomain boundary. The first test now looks like this:
[Test]
public void ForumController_PassAllRootMessagesToView()
{
DoGet(string.Format("forum/{0}/index.rails", forumId));
AssertSuccess();
AssertFlashContains("messages.TotalItems");
int msgs = (int)Response.Flash["messages.TotalItems"];
Assert.AreEqual(50, msgs);
}
Moving on to writing the view itself, it looks like this:
<h2>Messages:</h2>
<table>
<tr>
<td>Title:</td>
<td>Author:</td>
</tr>
<?brail for msg in messages: ?>
<tr>
<td>
${msg.Title}
</td>
<td>
${msg.Author.Name}
</td>
</tr>
<?brail end ?>
</table>
Now, when we run the tests, everything pass. But we need to hanle paging as well. Currently we are just showing the first 10 rows. Here is a test that says we can page:
[Test]
public void ForumsView_HasPaging_AndChangeDataOnPaging()
{
DoGet(string.Format("forum/{0}/index.rails", forumId));
//has next link
AssertReplyContains("<a href=\"/forum/index.rails?page=2&forumId=1&\" >next</a>");
DoGet(string.Format("forum/{0}/index.rails", forumId), "page=3");
AssertReplyContains("Root 20");
AssertReplyContains("Root 29");
AssertReplyDoesNotContain("Root 19");
AssertReplyDoesNotContain("Root 31");
}
This is fairly brute force way of doing this, but I can't really this of a better way. Also, notice the way of passing query strings using DoGet(), if you try to concat strings to make a URL, it won't work. Anyway, we need paging, we are likely going to want to do this in more than one view since paging is a cross cutting concerns. We are going to utilize the CommonScripts functionality of Brail to get this. Add a subfolder called CommonScripts to the Views folder, and create a file called Paging.boo:
import Castle.MonoRail.Framework.Helpers
import System.Text
import System.Collections
def Pagination(paginationHelper as PaginationHelper,
items as IPaginatedPage,
htmlAttributes as IDictionary,
querySting as IDictionary):
output = StringBuilder()
output.Append("""<div class="pagination">
<table width="100%"
border="0">
<tr>
<td>Showing ${items.FirstItem} - ${items.LastItem} of ${items.TotalItems}</td>
<td align="right">""")
if items.HasFirst:
output.Append( paginationHelper.CreatePageLink( 1, "first",htmlAttributes, querySting ) )
else:
output.Append( "first" )
end
output.Append( " | " )
if items.HasPrevious:
output.Append( paginationHelper.CreatePageLink( items.PreviousIndex, "prev",htmlAttributes, querySting ) )
else:
output.Append( "prev" )
end
output.Append( " | " )
if items.HasNext:
output.Append( paginationHelper.CreatePageLink( items.NextIndex, "next",htmlAttributes, querySting ) )
else:
output.Append( "next" )
end
output.Append( " | " )
if items.HasLast:
output.Append( paginationHelper.CreatePageLink( items.LastIndex, "last",htmlAttributes, querySting ) )
else:
output.Append( "last" )
end
output.Append( """</td>
</tr>
</table>
</div>""")
return output.ToString()
end
This looks like a lot of code, but it isn't doing much, actually. Just outputing the pagination html. Notice that we can specify the query string and the html attributes. Now, we can just append this at the bottom of the views:
<?brail
output Pagination( PaginationHelper, messages, null, { "forumId" : forumId} )
?>
Now run the tests, assure that they are passing, and then browse to the site and see what your easy work has gained you :-)
Now that we have a way of showing the forum's messages, we can add a link from the home page to the forum's page, and then we are done with the first scenario, this can be done as simply as:
<li>${forum.Id} - <a href="/forum/${forum.Id}/index.rails">${forum.Name}</a>
Now we finished with the first scenario, the second one calls for a text box that allows a quick access to the forum's page by its id. It turns out that we have to change only the view to make this happen, just adding this to /home/index.boo:
<div style="text-align: right;">
<form action="/forum/index.rails">
Go to forum #
<input type="text" name="forumId"/>
<input type="submit" value="Go!"/>
</form>
</div>
How this works? Remember that the ForumController.Index() method accept an integer? This simply sends it to it. MonoRail doesn't care if how we pass the value, it does the Right Thing :-D
But, this raise the question of input validation. We now have input from the user, and they are very good in putting the wrong data just to spite me.
Looking at this, I can see three edge-cases:
- Entering an id that doesn't belong to a forum.
- Not entering anything.
- Entering a non-numeric value.
Let us protect ourself from the first case:
public void Index(int forumId)
{
Forum forum = Forum.TryFind(forumId);
if(forum==null)
{
PropertyBag["ForumId"] = forumId;
RenderView("NoSuchForum");
return;
}
string paginationCacheKey = string.Format("forum.{0}.messages", forumId);
DataObtentionDelegate getMessageForForum = delegate
{
return Message.FindAll(Expression.Eq("Forum", forum), Expression.IsNull("RootParent"));
};
IPaginatedPage cachedPagination = PaginationHelper.CreateCachedPagination(paginationCacheKey,10,
getMessageForForum);
PropertyBag["messages"] = cachedPagination;
Flash["messages.TotalItems"] = cachedPagination.TotalItems;
}
We simply add a check to ensure that the forum exists. In both cases, we show a view that simply says: (Views\Forum\NoSuchForum.boo)
<h2>No Such Forum</h2>
<p>
Could not find a forum with id ${ForumId}
</p>
This still doesn't handle the last case, where the user pass a non-numeric data. How would we handle this? We can try to catch an error from the framework when it is trying to convert the value to integer, and handle that, but that is not an elegant solution in my opinion. A much more elegant solution is to think about it this way, an invalid integer value is a string, and MonoRail actually supports overloading, so... here is how we handle the other two cases:
public void Index(string forumId)
{
if (string.IsNullOrEmpty(forumId))
PropertyBag["ForumId"] = "null / empty";
else
PropertyBag["ForumId"] = forumId;
RenderView("NoSuchForum");
}
Now MonoRail will do the right thing, and call our Index(int) if the value can be converted to integer, and call Index(string) if it can't. We moved our error handling outside the method, not it is much easier to read and work with. Also, we are reusing the UI, the NoSuchForum view is being used by two actions on the controller. That is something that is usually harder to do (in terms of the setup require to make the code reusable), isn't it?
Forth scenario, we need to be able to add messages to a forum. Let us extend the forum/index view to handle this. I think that I want to use a drop of ajax for this, so we need to add this to Views\Layouts\default.boo, somewhere on the <head>:
${AjaxHelper.GetJavascriptFunctions()}
This makes sure that prototype is setup appropriately. Now, we can just append this at the end:
<p>
${ AjaxHelper.LinkToFunction('Post new message...', '$(newMsg).toggle()') }
</p>
<div id="newMsg" class="postNewMsg" style="display: none;">
<form method="POST">
<table>
<tr>
<td>Title:</td>
<td><input name="msg.Title" type="text"/>
</td>
</tr>
<tr>
<td>Author Id:</td>
<td><input name="msg.Author.Id" type="text"/>
</td>
</tr>
<tr>
<td>Content:</td>
<td></td>
</tr>
<tr>
<td colspan="2">
<textarea name="msg.Content" rows="10" cols="50"></textarea>
</td>
</tr>
</table>
<input type="submit" value="Post new message"/>
</form>
</div>
This just add a link that when press, will show the add new post form. I am asking the user for its ID, and that isn't really nice, but I don't want to get into authentication, that is not the purpose of this post. Here is the code that handles the actual saving of the message:
public void PostMsg([DataBind("msg")] Message msg)
{
msg.Create();
string paginationCacheKey = string.Format("forum.{0}.messages", msg.Forum.Id);
Context.Cache.Remove(paginationCacheKey);
CancelView();
Response.Redirect(string.Format("/Forum/Index.rails?forumId={0}", msg.Forum.Id));
}
How did it do that? I hear you asking. Well, it is utilizing a very smart behavior on the side of MonoRail. If you look closely, you'll notice that I naming the input fields in the form (and the query string) with an object notation. "msg.Forum.Id", for instnace. Then, all I need to do is to tell MonoRail how it show build this type for me.
After creating the new message, I clear the cache for this forum, and redirect to the main page. So, we have only two more scenarios to go (That is assuming that you are still reading this, of course).
Let us start by showing how we can display hierarchical data. We need to get all the root messages, and all their children, and so on. But right now we don't have a way to get from a Message to its children, or from a root message to all its hierarchy. Seems like we have a problem in the model (let us call a meeting).
After thinking about it, I decided that the following properties need to be added:
[HasMany(typeof(Message), "Parent", "Message")]
public ISet Children
{
get { return children; }
set { children = value; }
}
[HasMany(typeof(Message), "RootParent", "Message")]
public ISet Hierarchy
{
get { return hierarchy; }
set { hierarchy = value; }
}
And, the Parent needs to be modified slightly:
[BelongsTo]
public Message Parent
{
get { return parent; }
set
{
parent = value;
if(parent==null)
return;
RootParent = parent.RootParent ?? parent;
}
}
This is done to ensure that the RootParent is set properly in databinding scenarios. Now we can build the controller's action:
public void MessageDetails(int messageId)
{
SimpleQuery<Message> simpleQuery = new SimpleQuery<Message>(
@"from Message msg left join fetch msg.Hierarchy where msg.Id = ?",
messageId);
Message[] messages = simpleQuery.Execute();
if(messages.Length==0)
{
PropertyBag["MessageId"] = messageId;
RenderView("NoSuchMsg");
return;
}
PropertyBag["message"] = messages[0];
}
Now you may begin to see what is the value in the RootParent, it allows us to pull all the message hierarchy efficently. We get it from the database, check that the value exists in the database, and then populate the PropertyBag with the value. Now, to the view, which is a little complicated:
<?brail
import System.Text
import System.Collections
def Messages(messages as ICollection):
output "<ul>"
for msg in messages:
?>
<li>Title: ${msg.Title}<br/>
Author: ${msg.Author.Name}<br/>
${msg.Content}</li><br/>"""
<?brail output AjaxHelper.LinkToFunction('Post reply...', "$('newMsg.${msg.Id}').toggle()") ?>
<div id="newMsg.${msg.Id}"
class="postNewMsg"
style="display: none;">
<form method="POST"
action="/forum/postMsg.rails?msg.Forum.Id=${message.Forum.Id}&msg.Parent.Id=${msg.Id}">
<table>
<tr>
<td>Title:</td>
<td><input name="msg.Title"
type="text"/>
</td>
</tr>
<tr>
<td>Author Id:</td>
<td><input name="msg.Author.Id"
type="text"/>
</td>
</tr>
<tr>
<td>Content:</td>
<td></td>
</tr>
<tr>
<td colspan="2">
<textarea name="msg.Content" rows="10" cols="50"></textarea>
</td>
</tr>
</table>
<input type="submit" value="Post new message"/>
</form>
</div>
<?brail
if msg.Children.Count >0:
Messages(msg.Children)
end
end
output "</ul>"
end
?>
<h2>Message ${message.Id}</h2>
Title: ${message.Title}<br/>
Author: ${message.Author.Name}<br/>
${message.Content}
<?brail Messages( message.Children ) ?>
Most of this is HTML, though. What is happening is that we output the first message, and then recursively all its children, with a reply link that will open in place. This seems to the end of it, we have finished implementing all the scenarios.