FEATURE: Cook drafts excerpt in user activity (#14315)

The previous excerpt was a simple truncated raw message. Starting with
this commit, the raw content of the draft is cooked and an excerpt is
extracted from it. The logic for extracting the excerpt mimics the the
`ExcerptParser` class, but does not implement all functionality, being
a much simpler implementation.

The two draft controllers have been merged into one and the /draft.json
route has been changed to /drafts.json to be consistent with the other
route names.
This commit is contained in:
Dan Ungureanu 2021-09-14 15:18:01 +03:00 committed by GitHub
parent dde66b9e16
commit f517b6997c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 438 additions and 437 deletions

View File

@ -110,3 +110,53 @@ export function emojiUrlFor(code) {
return buildEmojiUrl(code, opts);
}
}
function encode(str) {
return str.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function traverse(element, callback) {
if (callback(element)) {
element.childNodes.forEach((child) => traverse(child, callback));
}
}
export function excerpt(cooked, length) {
let result = "";
let resultLength = 0;
const div = document.createElement("div");
div.innerHTML = cooked;
traverse(div, (element) => {
if (resultLength >= length) {
return;
}
if (element.nodeType === Node.TEXT_NODE) {
if (resultLength + element.textContent.length > length) {
const text = element.textContent.substr(0, length - resultLength);
result += encode(text);
result += "&hellip;";
resultLength += text.length;
} else {
result += encode(element.textContent);
resultLength += element.textContent.length;
}
} else if (element.tagName === "A") {
element.innerHTML = element.innerText;
result += element.outerHTML;
resultLength += element.innerText.length;
} else if (element.tagName === "IMG") {
if (element.classList.contains("emoji")) {
result += element.outerHTML;
} else {
result += "[image]";
resultLength += "[image]".length;
}
} else {
return true;
}
});
return result;
}

View File

@ -5,17 +5,14 @@ const Draft = EmberObject.extend();
Draft.reopenClass({
clear(key, sequence) {
return ajax("/draft.json", {
return ajax(`/drafts/${key}.json`, {
type: "DELETE",
data: { draft_key: key, sequence },
});
},
get(key) {
return ajax("/draft.json", {
data: { draft_key: key },
dataType: "json",
});
return ajax(`/drafts/${key}.json`);
},
getLocal(key, current) {
@ -25,7 +22,7 @@ Draft.reopenClass({
save(key, sequence, data, clientId, { forceSave = false } = {}) {
data = typeof data === "string" ? data : JSON.stringify(data);
return ajax("/draft.json", {
return ajax("/drafts.json", {
type: "POST",
data: {
draft_key: key,

View File

@ -1,105 +1,100 @@
import discourseComputed from "discourse-common/utils/decorators";
import { ajax } from "discourse/lib/ajax";
import { cookAsync, emojiUnescape, excerpt } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import {
NEW_PRIVATE_MESSAGE_KEY,
NEW_TOPIC_KEY,
} from "discourse/models/composer";
import { A } from "@ember/array";
import { Promise } from "rsvp";
import RestModel from "discourse/models/rest";
import UserDraft from "discourse/models/user-draft";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import { url } from "discourse/lib/computed";
import { Promise } from "rsvp";
export default RestModel.extend({
loaded: false,
limit: 30,
loading: false,
hasMore: false,
content: null,
init() {
this._super(...arguments);
this.reset();
},
reset() {
this.setProperties({
itemsLoaded: 0,
loading: false,
hasMore: true,
content: [],
lastLoadedUrl: null,
});
},
baseUrl: url(
"itemsLoaded",
"user.username_lower",
"/drafts.json?offset=%@&username=%@"
),
load(site) {
this.setProperties({
itemsLoaded: 0,
content: [],
lastLoadedUrl: null,
site: site,
});
return this.findItems();
},
@discourseComputed("content.length", "loaded")
noContent(contentLength, loaded) {
return loaded && contentLength === 0;
@discourseComputed("content.length", "loading")
noContent(contentLength, loading) {
return contentLength === 0 && !loading;
},
remove(draft) {
let content = this.content.filter(
(item) => item.draft_key !== draft.draft_key
this.set(
"content",
this.content.filter((item) => item.draft_key !== draft.draft_key)
);
this.setProperties({ content, itemsLoaded: content.length });
},
findItems() {
let findUrl = this.baseUrl;
const lastLoadedUrl = this.lastLoadedUrl;
if (lastLoadedUrl === findUrl) {
return Promise.resolve();
findItems(site) {
if (site) {
this.set("site", site);
}
if (this.loading) {
if (this.loading || !this.hasMore) {
return Promise.resolve();
}
this.set("loading", true);
return ajax(findUrl)
const url = `/drafts.json?offset=${this.content.length}&limit=${this.limit}`;
return ajax(url)
.then((result) => {
if (result && result.no_results_help) {
if (!result) {
return;
}
if (result.no_results_help) {
this.set("noContentHelp", result.no_results_help);
}
if (result && result.drafts) {
const copy = A();
result.drafts.forEach((draft) => {
let draftData = JSON.parse(draft.data);
draft.post_number = draftData.postId || null;
if (!result.drafts) {
return;
}
this.set("hasMore", result.drafts.size >= this.limit);
const promises = result.drafts.map((draft) => {
draft.data = JSON.parse(draft.data);
return cookAsync(draft.data.reply).then((cooked) => {
draft.excerpt = excerpt(cooked.string, 300);
draft.post_number = draft.data.postId || null;
if (
draft.draft_key === NEW_PRIVATE_MESSAGE_KEY ||
draft.draft_key === NEW_TOPIC_KEY
) {
draft.title = draftData.title;
draft.title = draft.data.title;
}
draft.title = emojiUnescape(escapeExpression(draft.title));
if (draft.category_id) {
if (draft.data.categoryId) {
draft.category =
this.site.categories.findBy("id", draft.category_id) || null;
this.site.categories.findBy("id", draft.data.categoryId) ||
null;
}
this.content.push(UserDraft.create(draft));
});
});
copy.pushObject(UserDraft.create(draft));
});
this.content.pushObjects(copy);
this.setProperties({
loaded: true,
itemsLoaded: this.itemsLoaded + result.drafts.length,
});
}
return Promise.all(promises);
})
.finally(() => {
this.set("loading", false);
this.set("lastLoadedUrl", findUrl);
});
},
});

View File

@ -2,8 +2,9 @@ import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
model() {
let userDraftsStream = this.modelFor("user").get("userDraftsStream");
return userDraftsStream.load(this.site).then(() => userDraftsStream);
const model = this.modelFor("user").get("userDraftsStream");
model.reset();
return model.findItems(this.site).then(() => model);
},
renderTemplate() {

View File

@ -13,7 +13,7 @@ acceptance("Composer - Draft saving", function (needs) {
const draftThatWillBeSaved = "This_will_be_saved_successfully";
needs.pretender((server, helper) => {
server.post("/draft.json", (request) => {
server.post("/drafts.json", (request) => {
const success = request.requestBody.includes(draftThatWillBeSaved);
return success
? helper.response({ success: true })

View File

@ -8,7 +8,7 @@ acceptance("Composer - Edit conflict", function (needs) {
let lastBody;
needs.pretender((server, helper) => {
server.post("/draft.json", (request) => {
server.post("/drafts.json", (request) => {
lastBody = request.requestBody;
return helper.response({ success: true });
});

View File

@ -2,6 +2,7 @@ import {
acceptance,
count,
exists,
query,
queryAll,
visible,
} from "discourse/tests/helpers/qunit-helpers";
@ -36,4 +37,17 @@ acceptance("User Drafts", function (needs) {
"A fun new topic for testing drafts."
);
});
test("Stream - has excerpt", async function (assert) {
await visit("/u/eviltrout/activity/drafts");
assert.ok(exists(".user-stream-item"), "has drafts");
assert.equal(
query(".user-stream-item:nth-child(3) .category").textContent,
"meta"
);
assert.equal(
query(".user-stream-item:nth-child(3) .excerpt").innerHTML.trim(),
'here goes a reply to a PM <img src="/images/emoji/google_classic/slight_smile.png?v=10" title=":slight_smile:" class="emoji" alt=":slight_smile:">'
);
});
});

View File

@ -1,21 +0,0 @@
export default {
"/draft.json": {
draft: null,
draft_sequence: 0
},
"/draft.json?draft_key=topic_280": {
draft:
'{"reply":"This is a draft of the first post","action":"reply","categoryId":1,"archetypeId":"regular","metaData":null,"composerTime":2863,"typingTime":200}',
draft_sequence: 42
},
"/draft.json?draft_key=topic_281": {
draft:
'{"reply":"dum de dum da ba.","action":"createTopic","title":"dum da ba dum dum","categoryId":null,"archetypeId":"regular","metaData":null,"composerTime":540879,"typingTime":3400}',
draft_sequence: 0
},
"/draft.json?draft_key=topic_9": {
draft:
'{"reply":"This is a draft of the first post","action":"reply","categoryId":1,"archetypeId":"regular","metaData":null,"composerTime":2863,"typingTime":200}',
draft_sequence: 42
}
};

View File

@ -47,7 +47,7 @@ export default {
draft_username: "eviltrout",
avatar_template: "/user_avatar/localhost/eviltrout/{size}/2_1.png",
data:
'{"reply":"here goes a reply to a PM.","action":"reply","categoryId":null,"postId":212,"archetypeId":"regular","whisper":false,"metaData":null,"composerTime":455711,"typingTime":5400}',
'{"reply":"here goes a reply to a PM :slight_smile:","action":"reply","categoryId":3,"postId":212,"archetypeId":"regular","whisper":false,"metaData":null,"composerTime":455711,"typingTime":5400}',
topic_id: 93,
username: "eviltrout",
name: null,
@ -57,5 +57,20 @@ export default {
archetype: "private_message"
}
]
},
"/drafts/topic_280.json": {
draft:
'{"reply":"This is a draft of the first post","action":"reply","categoryId":1,"archetypeId":"regular","metaData":null,"composerTime":2863,"typingTime":200}',
draft_sequence: 42
},
"/drafts/topic_281.json": {
draft:
'{"reply":"dum de dum da ba.","action":"createTopic","title":"dum da ba dum dum","categoryId":null,"archetypeId":"regular","metaData":null,"composerTime":540879,"typingTime":3400}',
draft_sequence: 0
},
"/drafts/topic_9.json": {
draft:
'{"reply":"This is a draft of the first post","action":"reply","categoryId":1,"archetypeId":"regular","metaData":null,"composerTime":2863,"typingTime":200}',
draft_sequence: 42
}
};

View File

@ -291,8 +291,8 @@ export function applyDefaultHandlers(pretender) {
pretender.get("/permalink-check.json", () => response({ found: false }));
pretender.delete("/draft.json", success);
pretender.post("/draft.json", success);
pretender.delete("/drafts/:draft_key.json", success);
pretender.post("/drafts.json", success);
pretender.get("/u/:username/staff-info.json", () => response({}));
@ -358,19 +358,11 @@ export function applyDefaultHandlers(pretender) {
response(fixturesByUrl["/c/11/show.json"])
);
pretender.get("/draft.json", (request) => {
if (request.queryParams.draft_key === "new_topic") {
return response(fixturesByUrl["/draft.json"]);
} else if (request.queryParams.draft_key.startsWith("topic_")) {
return response(
fixturesByUrl[request.url] || {
draft: null,
draft_sequence: 0,
}
);
}
return response({});
});
pretender.get("/drafts.json", () => response(fixturesByUrl["/drafts.json"]));
pretender.get("/drafts/:draft_key.json", (request) =>
response(fixturesByUrl[request.url] || { draft: null, draft_sequence: 0 })
);
pretender.get("/drafts.json", () => response(fixturesByUrl["/drafts.json"]));

View File

@ -7,14 +7,10 @@ import UserDraft from "discourse/models/user-draft";
module("Unit | Model | user-draft", function () {
test("stream", function (assert) {
const user = User.create({ id: 1, username: "eviltrout" });
const stream = user.get("userDraftsStream");
const stream = user.userDraftsStream;
assert.present(stream, "a user has a drafts stream by default");
assert.equal(
stream.get("itemsLoaded"),
0,
"no items are loaded by default"
);
assert.blank(stream.get("content"), "no content by default");
assert.equal(stream.content.length, 0, "no items are loaded by default");
assert.blank(stream.content, "no content by default");
});
test("draft", function (assert) {
@ -30,7 +26,7 @@ module("Unit | Model | user-draft", function () {
assert.equal(drafts.length, 2, "drafts count is right");
assert.equal(
drafts[1].get("draftType"),
drafts[1].draftType,
I18n.t("drafts.new_topic"),
"loads correct draftType label"
);

View File

@ -1,86 +0,0 @@
# frozen_string_literal: true
class DraftController < ApplicationController
requires_login
skip_before_action :check_xhr, :preload_json
def show
raise Discourse::NotFound.new if params[:draft_key].blank?
seq = params[:sequence] || DraftSequence.current(current_user, params[:draft_key])
render json: { draft: Draft.get(current_user, params[:draft_key], seq), draft_sequence: seq }
end
def update
raise Discourse::NotFound.new if params[:draft_key].blank?
sequence =
begin
Draft.set(
current_user,
params[:draft_key],
params[:sequence].to_i,
params[:data],
params[:owner],
force_save: params[:force_save]
)
rescue Draft::OutOfSequence
begin
if !Draft.exists?(user_id: current_user.id, draft_key: params[:draft_key])
Draft.set(
current_user,
params[:draft_key],
DraftSequence.current(current_user, params[:draft_key]),
params[:data],
params[:owner]
)
else
raise Draft::OutOfSequence
end
rescue Draft::OutOfSequence
render_json_error I18n.t('draft.sequence_conflict_error.title'),
status: 409,
extras: {
description: I18n.t('draft.sequence_conflict_error.description')
}
return
end
end
json = success_json.merge(draft_sequence: sequence)
begin
data = JSON::parse(params[:data])
rescue JSON::ParserError
raise Discourse::InvalidParameters.new(:data)
end
if data.present?
# this is a bit of a kludge we need to remove (all the parsing) too many special cases here
# we need to catch action edit and action editSharedDraft
if data["postId"].present? && data["originalText"].present? && data["action"].to_s.start_with?("edit")
post = Post.find_by(id: data["postId"])
if post && post.raw != data["originalText"]
conflict_user = BasicUserSerializer.new(post.last_editor, root: false)
render json: json.merge(conflict_user: conflict_user)
return
end
end
end
render json: json
end
def destroy
begin
Draft.clear(current_user, params[:draft_key], params[:sequence].to_i)
rescue Draft::OutOfSequence
# nothing really we can do here, if try clearing a draft that is not ours, just skip it.
end
render json: success_json
end
end

View File

@ -6,29 +6,96 @@ class DraftsController < ApplicationController
skip_before_action :check_xhr, :preload_json
def index
params.require(:username)
params.permit(:offset)
params.permit(:limit)
user = fetch_user_from_params
unless user == current_user
raise Discourse::InvalidAccess
end
opts = {
user: user,
stream = Draft.stream(
user: current_user,
offset: params[:offset],
limit: params[:limit]
}
stream = Draft.stream(opts)
)
render json: {
drafts: stream ? serialize_data(stream, DraftSerializer) : [],
no_results_help: I18n.t("user_activity.no_drafts.self")
}
end
def show
raise Discourse::NotFound.new if params[:id].blank?
seq = params[:sequence] || DraftSequence.current(current_user, params[:id])
render json: { draft: Draft.get(current_user, params[:id], seq), draft_sequence: seq }
end
def create
raise Discourse::NotFound.new if params[:draft_key].blank?
sequence =
begin
Draft.set(
current_user,
params[:draft_key],
params[:sequence].to_i,
params[:data],
params[:owner],
force_save: params[:force_save]
)
rescue Draft::OutOfSequence
begin
if !Draft.exists?(user_id: current_user.id, draft_key: params[:draft_key])
Draft.set(
current_user,
params[:draft_key],
DraftSequence.current(current_user, params[:draft_key]),
params[:data],
params[:owner]
)
else
raise Draft::OutOfSequence
end
rescue Draft::OutOfSequence
render_json_error I18n.t('draft.sequence_conflict_error.title'),
status: 409,
extras: {
description: I18n.t('draft.sequence_conflict_error.description')
}
return
end
end
json = success_json.merge(draft_sequence: sequence)
begin
data = JSON::parse(params[:data])
rescue JSON::ParserError
raise Discourse::InvalidParameters.new(:data)
end
if data.present?
# this is a bit of a kludge we need to remove (all the parsing) too many special cases here
# we need to catch action edit and action editSharedDraft
if data["postId"].present? && data["originalText"].present? && data["action"].to_s.start_with?("edit")
post = Post.find_by(id: data["postId"])
if post && post.raw != data["originalText"]
conflict_user = BasicUserSerializer.new(post.last_editor, root: false)
render json: json.merge(conflict_user: conflict_user)
return
end
end
end
render json: json
end
def destroy
begin
Draft.clear(current_user, params[:id], params[:sequence].to_i)
rescue Draft::OutOfSequence
# nothing really we can do here, if try clearing a draft that is not ours, just skip it.
end
render json: success_json
end
end

View File

@ -161,7 +161,11 @@ class Draft < ActiveRecord::Base
end
def parsed_data
JSON.parse(data)
begin
JSON.parse(data)
rescue JSON::ParserError
{}
end
end
def topic_id

View File

@ -887,10 +887,7 @@ Discourse::Application.routes.draw do
get "message-bus/poll" => "message_bus#poll"
resources :drafts, only: [:index]
get "draft" => "draft#show"
post "draft" => "draft#update"
delete "draft" => "draft#destroy"
resources :drafts, only: [:index, :create, :show, :destroy]
if service_worker_asset = Rails.application.assets_manifest.assets['service-worker.js']
# https://developers.google.com/web/fundamentals/codelabs/debugging-service-workers/

View File

@ -1,175 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe DraftController do
it 'requires you to be logged in' do
post "/draft"
expect(response.status).to eq(403)
end
it 'saves a draft on update' do
user = sign_in(Fabricate(:user))
post "/draft.json", params: {
draft_key: 'xyz',
data: { my: "data" }.to_json,
sequence: 0
}
expect(response.status).to eq(200)
expect(Draft.get(user, 'xyz', 0)).to eq(%q({"my":"data"}))
end
it "returns 404 when the key is missing" do
_user = sign_in(Fabricate(:user))
post "/draft.json", params: { data: { my: "data" }.to_json, sequence: 0 }
expect(response.status).to eq(404)
end
it "returns a draft if requested" do
user = sign_in(Fabricate(:user))
Draft.set(user, 'hello', 0, 'test')
get "/draft.json", params: { draft_key: 'hello' }
expect(response.status).to eq(200)
json = response.parsed_body
expect(json['draft']).to eq('test')
get "/draft.json"
expect(response.status).to eq(404)
end
it 'checks for an conflict on update' do
user = sign_in(Fabricate(:user))
post = Fabricate(:post, user: user)
post "/draft.json", params: {
draft_key: "topic",
sequence: 0,
data: {
postId: post.id,
originalText: post.raw,
action: "edit"
}.to_json
}
expect(response.parsed_body['conflict_user']).to eq(nil)
post "/draft.json", params: {
draft_key: "topic",
sequence: 0,
data: {
postId: post.id,
originalText: "something else",
action: "edit"
}.to_json
}
json = response.parsed_body
expect(json['conflict_user']['id']).to eq(post.last_editor.id)
expect(json['conflict_user']).to include('avatar_template')
end
it 'destroys drafts when required' do
user = sign_in(Fabricate(:user))
Draft.set(user, 'xxx', 0, 'hi')
delete "/draft.json", params: { draft_key: 'xxx', sequence: 0 }
expect(response.status).to eq(200)
expect(Draft.get(user, 'xxx', 0)).to eq(nil)
end
it 'cant trivially resolve conflicts without interaction' do
user = sign_in(Fabricate(:user))
DraftSequence.next!(user, "abc")
post "/draft.json", params: {
draft_key: "abc",
sequence: 0,
data: { a: "test" }.to_json,
owner: "abcdefg"
}
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["draft_sequence"]).to eq(1)
end
it 'has a clean protocol for ownership handover' do
user = sign_in(Fabricate(:user))
post "/draft.json", params: {
draft_key: "abc",
sequence: 0,
data: { a: "test" }.to_json,
owner: "abcdefg"
}
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["draft_sequence"]).to eq(0)
post "/draft.json", params: {
draft_key: "abc",
sequence: 0,
data: { b: "test" }.to_json,
owner: "hijklmnop"
}
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["draft_sequence"]).to eq(1)
expect(DraftSequence.current(user, "abc")).to eq(1)
post "/draft.json", params: {
draft_key: "abc",
sequence: 1,
data: { c: "test" }.to_json,
owner: "hijklmnop"
}
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["draft_sequence"]).to eq(2)
post "/draft.json", params: {
draft_key: "abc",
sequence: 2,
data: { c: "test" }.to_json,
owner: "abc"
}
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["draft_sequence"]).to eq(3)
end
it 'raises an error for out-of-sequence draft setting' do
user = sign_in(Fabricate(:user))
seq = DraftSequence.next!(user, "abc")
Draft.set(user, "abc", seq, { b: "test" }.to_json)
post "/draft.json", params: {
draft_key: "abc",
sequence: seq - 1,
data: { a: "test" }.to_json
}
expect(response.status).to eq(409)
post "/draft.json", params: {
draft_key: "abc",
sequence: seq + 1,
data: { a: "test" }.to_json
}
expect(response.status).to eq(409)
end
end

View File

@ -3,55 +3,210 @@
require 'rails_helper'
describe DraftsController do
it 'requires you to be logged in' do
get "/drafts.json"
expect(response.status).to eq(403)
describe "#index" do
it 'requires you to be logged in' do
get "/drafts.json"
expect(response.status).to eq(403)
end
it 'returns correct stream length after adding a draft' do
user = sign_in(Fabricate(:user))
Draft.set(user, 'xxx', 0, '{}')
get "/drafts.json"
expect(response.status).to eq(200)
parsed = response.parsed_body
expect(response.parsed_body["drafts"].length).to eq(1)
end
it 'has empty stream after deleting last draft' do
user = sign_in(Fabricate(:user))
Draft.set(user, 'xxx', 0, '{}')
Draft.clear(user, 'xxx', 0)
get "/drafts.json"
expect(response.status).to eq(200)
expect(response.parsed_body["drafts"].length).to eq(0)
end
it 'does not include topic details when user cannot see topic' do
topic = Fabricate(:private_message_topic)
topic_user = topic.user
other_user = Fabricate(:user)
Draft.set(topic_user, "topic_#{topic.id}", 0, '{}')
Draft.set(other_user, "topic_#{topic.id}", 0, '{}')
sign_in(topic_user)
get "/drafts.json"
expect(response.status).to eq(200)
expect(response.parsed_body["drafts"].first["title"]).to eq(topic.title)
sign_in(other_user)
get "/drafts.json"
expect(response.status).to eq(200)
expect(response.parsed_body["drafts"].first["title"]).to eq(nil)
end
end
it 'returns correct stream length after adding a draft' do
user = sign_in(Fabricate(:user))
Draft.set(user, 'xxx', 0, '{}')
get "/drafts.json", params: { username: user.username }
expect(response.status).to eq(200)
parsed = response.parsed_body
expect(parsed["drafts"].length).to eq(1)
describe "#show" do
it "returns a draft if requested" do
user = sign_in(Fabricate(:user))
Draft.set(user, 'hello', 0, 'test')
get "/drafts/hello.json"
expect(response.status).to eq(200)
expect(response.parsed_body['draft']).to eq('test')
end
end
it 'has empty stream after deleting last draft' do
user = sign_in(Fabricate(:user))
Draft.set(user, 'xxx', 0, '{}')
Draft.clear(user, 'xxx', 0)
get "/drafts.json", params: { username: user.username }
expect(response.status).to eq(200)
parsed = response.parsed_body
expect(parsed["drafts"].length).to eq(0)
describe "#create" do
it 'requires you to be logged in' do
post "/drafts.json"
expect(response.status).to eq(403)
end
it 'saves a draft' do
user = sign_in(Fabricate(:user))
post "/drafts.json", params: {
draft_key: 'xyz',
data: { my: "data" }.to_json,
sequence: 0
}
expect(response.status).to eq(200)
expect(Draft.get(user, 'xyz', 0)).to eq(%q({"my":"data"}))
end
it "returns 404 when the key is missing" do
sign_in(Fabricate(:user))
post "/drafts.json", params: { data: { my: "data" }.to_json, sequence: 0 }
expect(response.status).to eq(404)
end
it 'checks for an conflict on update' do
user = sign_in(Fabricate(:user))
post = Fabricate(:post, user: user)
post "/drafts.json", params: {
draft_key: "topic",
sequence: 0,
data: {
postId: post.id,
originalText: post.raw,
action: "edit"
}.to_json
}
expect(response.status).to eq(200)
expect(response.parsed_body['conflict_user']).to eq(nil)
post "/drafts.json", params: {
draft_key: "topic",
sequence: 0,
data: {
postId: post.id,
originalText: "something else",
action: "edit"
}.to_json
}
expect(response.status).to eq(200)
expect(response.parsed_body['conflict_user']['id']).to eq(post.last_editor.id)
expect(response.parsed_body['conflict_user']).to include('avatar_template')
end
it 'cant trivially resolve conflicts without interaction' do
user = sign_in(Fabricate(:user))
DraftSequence.next!(user, "abc")
post "/drafts.json", params: {
draft_key: "abc",
sequence: 0,
data: { a: "test" }.to_json,
owner: "abcdefg"
}
expect(response.status).to eq(200)
expect(response.parsed_body["draft_sequence"]).to eq(1)
end
it 'has a clean protocol for ownership handover' do
user = sign_in(Fabricate(:user))
post "/drafts.json", params: {
draft_key: "abc",
sequence: 0,
data: { a: "test" }.to_json,
owner: "abcdefg"
}
expect(response.status).to eq(200)
expect(response.parsed_body["draft_sequence"]).to eq(0)
post "/drafts.json", params: {
draft_key: "abc",
sequence: 0,
data: { b: "test" }.to_json,
owner: "hijklmnop"
}
expect(response.status).to eq(200)
expect(response.parsed_body["draft_sequence"]).to eq(1)
expect(DraftSequence.current(user, "abc")).to eq(1)
post "/drafts.json", params: {
draft_key: "abc",
sequence: 1,
data: { c: "test" }.to_json,
owner: "hijklmnop"
}
expect(response.status).to eq(200)
expect(response.parsed_body["draft_sequence"]).to eq(2)
post "/drafts.json", params: {
draft_key: "abc",
sequence: 2,
data: { c: "test" }.to_json,
owner: "abc"
}
expect(response.status).to eq(200)
expect(response.parsed_body["draft_sequence"]).to eq(3)
end
it 'raises an error for out-of-sequence draft setting' do
user = sign_in(Fabricate(:user))
seq = DraftSequence.next!(user, "abc")
Draft.set(user, "abc", seq, { b: "test" }.to_json)
post "/drafts.json", params: {
draft_key: "abc",
sequence: seq - 1,
data: { a: "test" }.to_json
}
expect(response.status).to eq(409)
post "/drafts.json", params: {
draft_key: "abc",
sequence: seq + 1,
data: { a: "test" }.to_json
}
expect(response.status).to eq(409)
end
end
it 'does not let a user see drafts stream of another user' do
user_b = Fabricate(:user)
Draft.set(user_b, 'xxx', 0, '{}')
sign_in(Fabricate(:user))
get "/drafts.json", params: { username: user_b.username }
expect(response.status).to eq(403)
end
it 'does not include topic details when user cannot see topic' do
topic = Fabricate(:private_message_topic)
topic_user = topic.user
other_user = Fabricate(:user)
Draft.set(topic_user, "topic_#{topic.id}", 0, '{}')
Draft.set(other_user, "topic_#{topic.id}", 0, '{}')
sign_in(topic_user)
get "/drafts.json", params: { username: topic_user.username }
expect(response.status).to eq(200)
parsed = response.parsed_body
expect(parsed["drafts"].first["title"]).to eq(topic.title)
sign_in(other_user)
get "/drafts.json", params: { username: other_user.username }
expect(response.status).to eq(200)
parsed = response.parsed_body
expect(parsed["drafts"].first["title"]).to eq(nil)
describe "#destroy" do
it 'destroys drafts when required' do
user = sign_in(Fabricate(:user))
Draft.set(user, 'xxx', 0, 'hi')
delete "/drafts/xxx.json", params: { sequence: 0 }
expect(response.status).to eq(200)
expect(Draft.get(user, 'xxx', 0)).to eq(nil)
end
end
end

View File

@ -478,7 +478,7 @@ RSpec.describe SessionController do
UserAuthToken.destroy_all
# we need a route that will call current user
post '/draft.json', params: {}
post '/drafts.json', params: {}
expect(response.headers['Discourse-Logged-Out']).to eq("1")
end
end