[Notice] Announcing the End of Demo Server [Read me]
Yi EungJun 2013-04-15
project: Add Tagging feature.
While getting tags:
* Return 406 if the client cannot accept appcation/json.

While tagging:
* Return the tag if that is tagged newly.
* Create new tag if no tag matches the given name.
* Do nothing if that has been tagged already.

While untagging:
* Respond 404 if no tag matches the given id.
* Delete the tag if there is no project tagged by that.
@5be44e381b9e24774fe22ecff92007dc0420efbd
app/controllers/ProjectApp.java
--- app/controllers/ProjectApp.java
+++ app/controllers/ProjectApp.java
@@ -1,5 +1,6 @@
 package controllers;
 
+import com.avaje.ebean.ExpressionList;
 import com.avaje.ebean.Page;
 import models.*;
 import models.enumeration.Operation;
@@ -16,13 +17,17 @@
 import playRepository.RepositoryService;
 import utils.AccessControl;
 import utils.Constants;
+import utils.HttpUtil;
 import views.html.project.*;
 
 import java.io.File;
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
+import java.util.HashMap;
+import java.util.Map;
 
 import static play.data.Form.form;
+import static play.libs.Json.toJson;
 
 /**
  * @author "Hwi Ahn"
@@ -245,4 +250,72 @@
 
         return ok(projectList.render("title.projectList", projects, filter, state));
     }
+
+    public static Result tags(String ownerName, String projectName) {
+        Project project = Project.findByNameAndOwner(ownerName, projectName);
+        if (!AccessControl.isAllowed(UserApp.currentUser(), project.asResource(), Operation.READ)) {
+            return forbidden();
+        }
+
+        if (!request().accepts("application/json")) {
+            return status(406);
+        }
+
+        Map<Long, String> tags = new HashMap<Long, String>();
+        for (Tag tag: project.tags) {
+            tags.put(tag.id, tag.name);
+        }
+
+        return ok(toJson(tags));
+    }
+
+    public static Result tag(String ownerName, String projectName) {
+        Project project = Project.findByNameAndOwner(ownerName, projectName);
+        if (!AccessControl.isAllowed(UserApp.currentUser(), project.asResource(), Operation.UPDATE)) {
+            return forbidden();
+        }
+
+        // Get tag name from the request. Return empty map if the name is not given.
+        Map<String, String[]> data = request().body().asFormUrlEncoded();
+        String name = HttpUtil.getFirstValueFromQuery(data, "name");
+        if (name == null || name.length() == 0) {
+            return ok(toJson(new HashMap<Long, String>()));
+        }
+
+        Tag tag = project.tag(name);
+
+        if (tag == null) {
+            // Return empty map if the tag has been already attached.
+            return ok(toJson(new HashMap<Long, String>()));
+        } else {
+            // Return the tag.
+            Map<Long, String> tags = new HashMap<Long, String>();
+            tags.put(tag.id, tag.name);
+            return ok(toJson(tags));
+        }
+    }
+
+    public static Result untag(String ownerName, String projectName, Long id) {
+        Project project = Project.findByNameAndOwner(ownerName, projectName);
+        if (!AccessControl.isAllowed(UserApp.currentUser(), project.asResource(), Operation.UPDATE)) {
+            return forbidden();
+        }
+
+        // _method must be 'delete'
+        Map<String, String[]> data = request().body().asFormUrlEncoded();
+        if (!HttpUtil.getFirstValueFromQuery(data, "_method").toLowerCase()
+                .equals("delete")) {
+            return badRequest("_method must be 'delete'.");
+        }
+
+        Tag tag = Tag.find.byId(id);
+
+        if (tag == null) {
+            return notFound();
+        }
+
+        project.untag(tag);
+
+        return status(204);
+    }
 }
 
app/controllers/TagApp.java (added)
+++ app/controllers/TagApp.java
@@ -0,0 +1,48 @@
+package controllers;
+
+import com.avaje.ebean.ExpressionList;
+import models.Project;
+import models.Tag;
+import models.enumeration.Operation;
+import play.mvc.Controller;
+import play.mvc.Result;
+import utils.AccessControl;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static play.libs.Json.toJson;
+
+/**
+ * Created with IntelliJ IDEA.
+ * User: nori
+ * Date: 13. 4. 12
+ * Time: 오후 3:37
+ * To change this template use File | Settings | File Templates.
+ */
+public class TagApp extends Controller {
+    private static final int MAX_FETCH_TAGS = 1000;
+
+    public static Result tags(String query) {
+        if (!request().accepts("application/json")) {
+            return status(406);
+        }
+
+        ExpressionList<Tag> el = Tag.find.where().contains("name", query);
+        int total = el.findRowCount();
+        if (total > MAX_FETCH_TAGS) {
+            el.setMaxRows(MAX_FETCH_TAGS);
+            response().setHeader("Content-Range", "items " + MAX_FETCH_TAGS + "/" + total);
+        }
+
+        Map<Long, String> tags = new HashMap<Long, String>();
+        for (Tag tag: el.findList()) {
+            tags.put(tag.id, tag.name);
+        }
+
+        return ok(toJson(tags));
+    }
+
+}
app/models/Project.java
--- app/models/Project.java
+++ app/models/Project.java
@@ -3,11 +3,7 @@
 import java.io.IOException;
 import java.util.*;
 
-import javax.persistence.CascadeType;
-import javax.persistence.Entity;
-import javax.persistence.Id;
-import javax.persistence.OneToMany;
-import javax.persistence.OneToOne;
+import javax.persistence.*;
 import javax.servlet.ServletException;
 import javax.validation.constraints.NotNull;
 
@@ -80,6 +76,9 @@
 
     private long lastIssueNumber;
     private long lastPostingNumber;
+
+    @ManyToMany
+    public Set<Tag> tags;
 
     public static Long create(Project newProject) {
 		newProject.siteurl = "http://localhost:9000/" + newProject.name;
@@ -315,4 +314,33 @@
         return User.findByLoginId(userId);
     }
 
+    public Tag tag(String tagName) {
+        // Find a tag by the given name.
+        Tag tag = Tag.find.where().eq("name", tagName).findUnique();
+
+        if (tag == null) {
+            // Create new tag if there is no tag which has the given name.
+            tag = new Tag();
+            tag.name = tagName;
+            tag.save();
+        } else if (tag.projects.contains(this)) {
+            // Return empty map if the tag has been already attached.
+            return null;
+        }
+
+        // Attach new tag.
+        tag.projects.add(this);
+        tag.update();
+
+        return tag;
+    }
+
+    public void untag(Tag tag) {
+        tag.projects.remove(this);
+        if (tag.projects.size() == 0) {
+            tag.delete();
+        } else {
+            tag.update();
+        }
+    }
 }
 
app/models/Tag.java (added)
+++ app/models/Tag.java
@@ -0,0 +1,71 @@
+package models;
+
+import models.enumeration.ResourceType;
+import models.resource.Resource;
+import play.data.validation.Constraints.Required;
+import play.db.ebean.Model;
+
+import javax.persistence.*;
+import java.util.List;
+import java.util.Set;
+
+@Entity
+public class Tag extends Model {
+
+    /**
+     *
+     */
+    private static final long serialVersionUID = -35487506476718498L;
+    public static Finder<Long, Tag> find = new Finder<Long, Tag>(Long.class, Tag.class);
+
+    @Id
+    public Long id;
+
+    @Required
+    @Column(unique=true)
+    public String name;
+
+    @ManyToMany(mappedBy="tags")
+    public Set<Project> projects;
+
+    public static List<Tag> findByProjectId(Long projectId) {
+        return find.where().eq("project.id", projectId).findList();
+    }
+
+    public static Tag findById(Long id) {
+        return find.byId(id);
+    }
+
+    @Transient
+    public boolean exists() {
+        return find.where().eq("name", name).findRowCount() > 0;
+    }
+
+    @Override
+    public void delete() {
+        for(Project project: projects) {
+            project.tags.remove(this);
+            project.save();
+        }
+        super.delete();
+    }
+
+    public Resource asResource() {
+        return new Resource() {
+            @Override
+            public Long getId() {
+                return id;
+            }
+
+            @Override
+            public Project getProject() {
+                return null;
+            }
+
+            @Override
+            public ResourceType getType() {
+                return ResourceType.TAG;
+            }
+        };
+    }
+}(No newline at end of file)
app/models/enumeration/ResourceType.java
--- app/models/enumeration/ResourceType.java
+++ app/models/enumeration/ResourceType.java
@@ -21,7 +21,8 @@
     PROJECT("project"),
     ATTACHMENT("attachment"),
     ISSUE_COMMENT("issue_comment"),
-    NONISSUE_COMMENT("nonissue_comment");
+    NONISSUE_COMMENT("nonissue_comment"),
+    TAG("tag");
 
     private String resource;
 
app/views/project/projectHome.scala.html
--- app/views/project/projectHome.scala.html
+++ app/views/project/projectHome.scala.html
@@ -27,7 +27,10 @@
                     <strong>라이센스 :</strong> GPL v2
                 </li>
                 <li class="info">
-                    <strong>운영체제 :</strong> 리눅스
+                    <strong>@Messages("project.tags") :</strong>
+                    @for(tag <- project.tags) {
+                    <span class='label label-info'>@tag.name</span>
+                    }
                 </li>
                 <li class="info">
                     <strong>프로그래밍 언어 :</strong> PHP, Python, Java
app/views/project/projectList.scala.html
--- app/views/project/projectList.scala.html
+++ app/views/project/projectList.scala.html
@@ -37,6 +37,9 @@
                   <div class="header">
                       <a href="@routes.UserApp.userInfo(project.owner)">@project.owner</a> / <a href="@routes.ProjectApp.project(project.owner, project.name)" class="project-name">@project.name</a>
             		  @if(!project.share_option){ <i class="ico ico-lock"></i> }
+                      @for(tag <- project.tags) {
+                      <span class='label label-info'>@tag.name</span>
+                      }
                   </div>
                   <div class="desc">
                       @project.overview
app/views/project/setting.scala.html
--- app/views/project/setting.scala.html
+++ app/views/project/setting.scala.html
@@ -54,6 +54,17 @@
                 </dl>
             </div>
             <div class="box-wrap middle">
+                <div class="cu-label">@Messages("project.tags")</div>
+                <div class="cu-desc">
+                    <div id="tags">
+                        <!-- tags will be added here by hive.project.Settings.js -->
+                    </div>
+                    <input name="newTag" type="text" class="text" style="margin-bottom: 0px"
+                        data-provider="typeahead" autocomplete="off"/>
+                    <a href="javascript:void(0)" class="n-btn small orange" id="addTag">@Messages("button.add")</a>
+                </div>
+            </div>
+            <div class="box-wrap middle">
                 <div class="cu-label">@Messages("project.shareOption")</div>
                 <div class="cu-desc">
                     <input name="share_option" type="radio" @if(project.share_option == true){checked="checked"} id="public" value="true" class="radio-btn"><label for="public" class="bg-radiobtn label-public">@Messages("project.public")</label>
@@ -103,8 +114,12 @@
 
 <script type="text/javascript">
 	$(document).ready(function(){
-		$hive.loadModule("project.Setting");
+        $hive.loadModule("project.Setting", {
+			"sURLProjectTags": "@routes.ProjectApp.tags(project.owner, project.name)",
+            "sURLTags"       : "@routes.TagApp.tags()"
+        });
 	});
+
 </script>
 
 }
app/views/user/info.scala.html
--- app/views/user/info.scala.html
+++ app/views/user/info.scala.html
@@ -100,6 +100,11 @@
 						</h3>
 						<div class="stream-desc-wrap">
 							<div class="stream-desc">
+                                <p class="tags">
+                                @for(tag <- project.tags) {
+                                    <span class='label label-info'>@tag.name</span>
+                                }
+                                </p>
 								<p class="nm">@project.overview</p>
 								<p class="date">Last updated @agoString(project.ago)</p>
 							</div>
 
conf/evolutions/default/8.sql (added)
+++ conf/evolutions/default/8.sql
@@ -0,0 +1,20 @@
+# --- !Ups
+
+CREATE TABLE project_tag (
+  project_id                     BIGINT NOT NULL,
+  tag_id                         BIGINT NOT NULL,
+  CONSTRAINT pk_project_tag PRIMARY KEY (project_id, tag_id));
+
+CREATE TABLE tag (
+  id                       BIGINT NOT NULL,
+  name                      VARCHAR(255),
+  CONSTRAINT uq_tag_name UNIQUE (NAME),
+  CONSTRAINT pk_tag PRIMARY KEY (ID));
+
+CREATE SEQUENCE tag_seq;
+
+# --- !Downs
+
+DROP SEQUENCE IF EXISTS tag_seq;
+DROP TABLE IF EXISTS project_tag;
+DROP TABLE IF EXISTS tag;
conf/messages.en
--- conf/messages.en
+++ conf/messages.en
@@ -249,6 +249,7 @@
 project.readme = You can see README.md here if you add it into the code repository.
 project.searchPlaceholder = search at current project
 project.wrongName = Project name is wrong
+project.tags = Tags
 
 #Site
 site.sidebar = Site Management
@@ -367,4 +368,4 @@
 
 #userinfo
 userinfo.myProjects = My Projects
-userinfo.starredProjects = Starred
(No newline at end of file)
+userinfo.starredProjects = Starred
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -248,6 +248,7 @@
 project.readme = 프로젝트에 대한 설명을 README.md 파일로 작성해서 코드저장소에 추가하면 이 곳에 나타납니다.
 project.searchPlaceholder = 현재 프로젝트에서 검색
 project.wrongName = 프로젝트 이름이 올바르지 않습니다.
+project.tags = 태그
 
 #Site
 site.sidebar = 사이트 관리
@@ -367,4 +368,4 @@
 
 #userinfo
 userinfo.myProjects = 내 프로젝트
-userinfo.starredProjects = 관심 프로젝트
(No newline at end of file)
+userinfo.starredProjects = 관심 프로젝트
conf/routes
--- conf/routes
+++ conf/routes
@@ -59,6 +59,12 @@
 POST    /:user/:project/post/:id/edit                   controllers.BoardApp.editPost(user, project, id:Long)
 GET     /:user/:project/post/:id/comment/:commentId/delete     controllers.BoardApp.deleteComment(user, project, id:Long, commentId:Long)
 
+# Tags
+GET    /tags                                            controllers.TagApp.tags(query: String ?= "")
+GET    /:user/:project/tags                             controllers.ProjectApp.tags(user, project)
+POST   /:user/:project/tags                             controllers.ProjectApp.tag(user, project)
+POST   /:user/:project/tags/:id                         controllers.ProjectApp.untag(user, project, id: Long)
+
 # Projects
 GET     /projectform                                    controllers.ProjectApp.newProjectForm()
 POST    /projects                                       controllers.ProjectApp.newProject()
public/javascripts/common/hive.Common.js
--- public/javascripts/common/hive.Common.js
+++ public/javascripts/common/hive.Common.js
@@ -239,6 +239,30 @@
 	function getTrim(sValue){
 		return sValue.trim().replace(htVar.rxTrim, '');
 	}
+
+    /**
+    * Return whether the given content range is an entire range for items.
+    * e.g) "items 10/10"
+    *
+    * @param {String} contentRange the vaule of Content-Range header from response
+    * @return {Boolean}
+    */
+    function isEntireRange(contentRange) {
+        var result, items, total;
+
+        if (contentRange) {
+            result = /items\s+([0-9]+)\/([0-9]+)/.exec(contentRange);
+            if (result) {
+                items = parseInt(result[1]);
+                total = parseInt(result[2]);
+                if (items < total) {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
 	
 	/* public Interface */
 	return {
@@ -249,7 +273,8 @@
 		"stopEvent": stopEvent,
 		"getContrastColor": getContrastColor,
 		"sendForm" : sendForm,
-		"getTrim"  : getTrim 
+		"getTrim"  : getTrim,
+        "isEntireRange": isEntireRange
 	};
 })();
 
public/javascripts/service/hive.project.Member.js
--- public/javascripts/service/hive.project.Member.js
+++ public/javascripts/service/hive.project.Member.js
@@ -110,8 +110,35 @@
 				}
 			});
 		}
-
+
+        /**
+        * Data source for loginId typeahead while adding new member.
+        *
+        * For more information, See "source" option at
+        * http://twitter.github.io/bootstrap/javascript.html#typeahead
+        *
+        * @param {String} query
+        * @param {Function} process
+        */
+        function _userTypeaheadSource(query, process) {
+            if (query.match(htVar.lastQuery) && htVar.isLastRangeEntire) {
+                process(htVar.cachedUsers);
+            } else {
+                $('<form action="/users" method="GET">')
+                    .append($('<input type="hidden" name="query">').val(query))
+                    .ajaxForm({
+                        "dataType": "json",
+                        "success": function(data, status, xhr) {
+                            htVar.isLastRangeEntire = $hive.isEntireRange(xhr.getResponseHeader('Content-Range'));
+                            htVar.lastQuery = query;
+                            htVar.cachedUsers = data;
+                            process(data);
+                        }
+                    }).submit();
+            }
+        }
+
 		_init(htOptions);
 	};
 	
-})("hive.project.Member");
(No newline at end of file)
+})("hive.project.Member");
public/javascripts/service/hive.project.Setting.js
--- public/javascripts/service/hive.project.Setting.js
+++ public/javascripts/service/hive.project.Setting.js
@@ -23,6 +23,7 @@
 			_initVar(htOpt);
 			_initElement(htOpt);
 			_attachEvent();
+            _updateTags();
 			
 			htVar.waPopOvers.popover();
 		}
@@ -34,8 +35,10 @@
 		function _initVar(htOptions){
 			htVar.rxLogoExt = /\.(gif|bmp|jpg|jpeg|png)$/i;
 			htVar.rxPrjName = /^[a-zA-Z0-9_][-a-zA-Z0-9_]+[^-]$/;
+            htVar.sURLProjectTags = htOptions.sURLProjectTags;
+            htVar.sURLTags = htOptions.sURLTags;
 		}
-		
+
 		/**
 		 * initialize element variables
 		 */
@@ -57,15 +60,24 @@
 			
 			// popovers
 			htVar.waPopOvers = $([$("#project_name"), $("#share_option_explanation"), $("#terms")]);
+
+            // tags
+            htElement.welInputAddTag = $('input[name="newTag"]');
+            htElement.welTags = $('#tags');
+            htElement.welBtnAddTag = $('#addTag');
 		}
-		
-		/**
+
+        /**
 		 * attach event handlers
 		 */
 		function _attachEvent(){
 			htElement.welInputLogo.change(_onChangeLogoPath);
 			htElement.welBtnDeletePrj.click(_onClickBtnDeletePrj);
 			htElement.welBtnSave.click(_onClickBtnSave);
+            htElement.welInputAddTag
+                .keypress(_onKeyPressNewTag)
+                .typeahead().data('typeahead').source = _tagTypeaheadSource;
+            htElement.welBtnAddTag.click(_submitTag);
 		}
 		
 		/**
@@ -110,6 +122,113 @@
 			return true;
 		}
 
+        /**
+        * Data source for tag typeahead while adding new tag.
+        *
+        * For more information, See "source" option at
+        * http://twitter.github.io/bootstrap/javascript.html#typeahead
+        *
+        * @param {String} query
+        * @param {Function} process
+        */
+        function _tagTypeaheadSource(query, process) {
+            if (query.match(htVar.lastQuery) && htVar.isLastRangeEntire) {
+                process(htVar.cachedTags);
+            } else {
+                $('<form method="GET">')
+                    .attr('action', htVar.sURLTags)
+                    .append($('<input type="hidden" name="query">').val(query))
+                    .ajaxForm({
+                        "dataType": "json",
+                        "success": function(tags, status, xhr) {
+                            var tagNames = [];
+                            for(var id in tags) {
+                                tagNames.push(tags[id]);
+                            }
+                            htVar.isLastRangeEntire = $hive.isEntireRange(
+                                xhr.getResponseHeader('Content-Range'));
+                            htVar.lastQuery = query;
+                            htVar.cachedTags = tagNames;
+                            process(tagNames);
+                        }
+                    }).submit();
+            }
+        };
+
+        /**
+        * Submit new tag to add that.
+        */
+        function _submitTag () {
+            $('<form method="POST">')
+                .attr('action', htVar.sURLProjectTags)
+                .append($('<input type="hidden" name="name">')
+                        .val(htElement.welInputAddTag.val()))
+                .ajaxForm({ "success": _appendTags })
+                .submit();
+        }
+
+        /**
+        * If user presses enter at newtag element, get list of tags from the
+        * server and show them in #tags div.
+        *
+        * @param {Object} oEvent
+        */
+        function _onKeyPressNewTag(oEvent) {
+            if (oEvent.keyCode == 13) {
+                _submitTag();
+                htElement.welInputAddTag.val("");
+                return false;
+            }
+        }
+
+        /**
+        * Get list of tags from the server and show them in #tags div.
+        */
+        function _updateTags() {
+            $('<form method="GET">')
+                .attr('action', htVar.sURLProjectTags)
+                .ajaxForm({
+                    "dataType": "json",
+                    "success": _appendTags
+                }).submit();
+        }
+
+        /**
+        * Make a tag element by given id and name.
+
+        * @param {String} sId
+        * @param {String} sName
+        */
+        function _createTag(sId, sName) {
+            var fDelTag = function(ev) {
+                $('<form method="POST">')
+                    .attr('action', htVar.sURLProjectTags + '/' + sId)
+                    .append($('<input type="hidden" name="_method" value="DELETE">'))
+                    .ajaxForm({
+                        "success": function(data, status, xhr) {
+                            welTag.remove();
+                        }
+                    }).submit();
+            };
+
+            var welTag = $("<span class='label label-info'>")
+                .text(sName + " ")
+                .append($("<a href='javascript:void(0)'>").text("x").click(fDelTag));
+
+            return welTag;
+        };
+
+        /**
+        * Append the given tags on #tags div to show them.
+        *
+        * @param {Object} htTags
+        */
+        function _appendTags(htTags) {
+            for(var sId in htTags) {
+                htElement.welTags.append(_createTag(sId, htTags[sId]));
+            }
+        };
+
 		_init(htOptions);
 	};
 	
 
test/controllers/ProjectAppTest.java (added)
+++ test/controllers/ProjectAppTest.java
@@ -0,0 +1,131 @@
+package controllers;
+
+import models.Project;
+import models.Tag;
+import models.User;
+import org.codehaus.jackson.JsonNode;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import play.libs.Json;
+import play.mvc.Result;
+import play.test.Helpers;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+import static org.fest.assertions.Assertions.assertThat;
+import static play.test.Helpers.*;
+
+public class ProjectAppTest {
+    @BeforeClass
+    public static void beforeClass() {
+        callAction(
+                routes.ref.Application.init()
+        );
+    }
+
+    @Test
+    public void tag() {
+        running(fakeApplication(Helpers.inMemoryDatabase()), new Runnable() {
+            public void run() {
+                //Given
+                Map<String,String> data = new HashMap<String,String>();
+                data.put("name", "foo");
+                User admin = User.findByLoginId("admin");
+
+                //When
+                Result result = callAction(
+                        controllers.routes.ref.ProjectApp.tag("hobi", "nForge4java"),
+                        fakeRequest()
+                                .withFormUrlEncodedBody(data)
+                                .withHeader("Accept", "application/json")
+                                .withSession(UserApp.SESSION_USERID, admin.id.toString())
+                );
+
+                //Then
+                assertThat(status(result)).isEqualTo(OK);
+                Iterator<Map.Entry<String, JsonNode>> fields = Json.parse(contentAsString(result)).getFields();
+                Map.Entry<String, JsonNode> field = fields.next();
+                Tag expected = new Tag();
+                expected.id = Long.valueOf(field.getKey());
+                expected.name = field.getValue().asText();
+                assertThat(expected.name).isEqualTo("foo");
+                assertThat(Project.findByNameAndOwner("hobi", "nForge4java").tags.contains(expected)).isTrue();
+            }
+        });
+    }
+
+    @Test
+    public void tags() {
+        running(fakeApplication(Helpers.inMemoryDatabase()), new Runnable() {
+            public void run() {
+                //Given
+                Project project = Project.findByNameAndOwner("hobi", "nForge4java");
+
+                Tag tag1 = new Tag();
+                tag1.name = "foo";
+                tag1.save();
+                project.tags.add(tag1);
+                project.update();
+
+                Tag tag2 = new Tag();
+                tag2.name = "bar";
+                tag2.save();
+                project.tags.add(tag2);
+                project.update();
+
+                //When
+
+                Result result = callAction(
+                        controllers.routes.ref.ProjectApp.tags("hobi", "nForge4java"),
+                        fakeRequest().withHeader("Accept", "application/json")
+                );
+
+                //Then
+                assertThat(status(result)).isEqualTo(OK);
+                JsonNode json = Json.parse(contentAsString(result));
+                assertThat(json.has(tag1.id.toString())).isTrue();
+                assertThat(json.has(tag2.id.toString())).isTrue();
+                assertThat(json.get(tag1.id.toString()).asText()).isEqualTo("foo");
+                assertThat(json.get(tag2.id.toString()).asText()).isEqualTo("bar");
+            }
+        });
+    }
+
+    @Test
+    public void untag() {
+        running(fakeApplication(Helpers.inMemoryDatabase()), new Runnable() {
+            public void run() {
+                //Given
+                Project project = Project.findByNameAndOwner("hobi", "nForge4java");
+
+                Tag tag1 = new Tag();
+                tag1.name = "foo";
+                tag1.save();
+                project.tags.add(tag1);
+                project.update();
+                Long tagId = tag1.id;
+
+                assertThat(project.tags.contains(tag1)).isTrue();
+
+                Map<String,String> data = new HashMap<String,String>();
+                data.put("_method", "DELETE");
+                User admin = User.findByLoginId("admin");
+
+                //When
+                Result result = callAction(
+                        controllers.routes.ref.ProjectApp.untag("hobi", "nForge4java", tagId),
+                        fakeRequest()
+                                .withFormUrlEncodedBody(data)
+                                .withHeader("Accept", "application/json")
+                                .withSession(UserApp.SESSION_USERID, admin.id.toString())
+                );
+
+                //Then
+                assertThat(status(result)).isEqualTo(204);
+                assertThat(Project.findByNameAndOwner("hobi", "nForge4java").tags.contains(tag1)).isFalse();
+            }
+        });
+    }
+}
Add a comment
List