Skip to content

Commit e66aac7

Browse files
authored
Merge pull request #34 from alphagov/dynamic-json-hash-content
Fact Check: Accept and validate JSON hashes for content fields
2 parents 9801d57 + 810de49 commit e66aac7

File tree

9 files changed

+218
-35
lines changed

9 files changed

+218
-35
lines changed

app/controllers/api/requests_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def create
2121
if fact_check_request.save
2222
render json: { id: fact_check_request.id, source_id: fact_check_request.source_id }, status: :created
2323
else
24-
render json: { errors: fact_check_request.errors.full_messages }, status: :bad_request
24+
render json: { errors: fact_check_request.errors.full_messages }, status: :unprocessable_entity
2525
end
2626
end
2727

@@ -37,7 +37,7 @@ def request_params
3737
:requester_email,
3838
:current_content,
3939
:previous_content, # optional
40-
:deadline, # optional
40+
:deadline,
4141
recipients: [],
4242
# dynamic hash fields at the end
4343
current_content: {},

app/models/request.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,28 @@ class Request < ApplicationRecord
33
has_many :users, through: :collaborations
44
has_one :response
55

6-
validates :source_id, :source_app, :requester_name, :requester_email, :status, :current_content, presence: true
6+
validates :source_id, :source_app, :requester_name, :requester_email, :status, :current_content, :deadline, presence: true
7+
validate :content_data_must_be_string_pairs
8+
79
scope :for_source, ->(source_id) { where(source_id: source_id) }
810
scope :most_recent_first, -> { order(created_at: :desc) }
911
scope :most_recent_for_source, ->(source_id) { for_source(source_id).most_recent_first.first }
12+
13+
def content_data_must_be_string_pairs
14+
%i[current_content previous_content].each do |content_field|
15+
content_hash = public_send(content_field)
16+
next if content_hash.blank?
17+
18+
unless content_hash.is_a?(Hash)
19+
errors.add(content_field, "#{content_field} is not a hash")
20+
next
21+
end
22+
23+
content_hash.each do |key, value|
24+
unless value.is_a?(String)
25+
errors.add(content_field, "value for #{key} must be a string")
26+
end
27+
end
28+
end
29+
end
1030
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class AddJsonCurrentContentColumnToRequests < ActiveRecord::Migration[8.0]
2+
def up
3+
add_column :requests, :json_current_content, :json
4+
Request.reset_column_information
5+
6+
# Convert old data and move it to this column
7+
Request.find_each do |request|
8+
if request.current_content.present?
9+
json_payload = { body: request.current_content }
10+
request.update_columns(json_current_content: json_payload)
11+
end
12+
end
13+
end
14+
15+
def down
16+
remove_column :requests, :json_current_content
17+
end
18+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class AddJsonPreviousContentColumnToRequests < ActiveRecord::Migration[8.0]
2+
def up
3+
add_column :requests, :json_previous_content, :json
4+
Request.reset_column_information
5+
6+
# Convert old data and move it to this column
7+
Request.find_each do |request|
8+
if request.previous_content.present?
9+
json_payload = { body: request.previous_content }
10+
request.update_columns(json_previous_content: json_payload)
11+
end
12+
end
13+
end
14+
15+
def down
16+
remove_column :requests, :json_previous_content
17+
end
18+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class CleanupRequestsContentColumns < ActiveRecord::Migration[8.0]
2+
change_table(:requests, bulk: true) do |t|
3+
# Remove old text columns, now that data has been converted to json
4+
t.remove :current_content, type: :text
5+
t.remove :previous_content, type: :text
6+
7+
# Rename new columns to replace removed column names
8+
t.rename :json_current_content, :current_content
9+
t.rename :json_previous_content, :previous_content
10+
end
11+
end

db/schema.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
# migrations use external dependencies or application code.
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
12-
1312
ActiveRecord::Schema[8.0].define(version: 2026_02_26_095141) do
13+
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "pg_catalog.plpgsql"
1616
enable_extension "pgcrypto"
@@ -31,14 +31,14 @@
3131
t.string "requester_name", null: false
3232
t.string "requester_email", null: false
3333
t.string "status", default: "new", null: false
34-
t.json "previous_content"
35-
t.json "current_content", null: false
36-
t.datetime "deadline"
34+
t.datetime "deadline", null: false
3735
t.datetime "created_at", null: false
3836
t.datetime "updated_at", null: false
3937
t.string "source_app", null: false
4038
t.string "source_url"
4139
t.string "source_title"
40+
t.json "current_content", null: false
41+
t.json "previous_content"
4242
t.index ["created_at"], name: "index_requests_on_created_at"
4343
t.index ["source_id"], name: "index_requests_on_source_id"
4444
end

spec/factories/request.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,21 @@
44
source_app { "publisher" }
55
requester_name { "Malcolm Tucker" }
66
requester_email { "m.tucker@gov.uk" }
7-
current_content { "<HTML> Changes go here <HTML>" }
7+
current_content do
8+
{
9+
"body":
10+
"Many lines of data for the content. Many changes that need fact checking",
11+
}
12+
end
813
deadline { Time.zone.now + 1.week }
14+
15+
trait :with_more_complex_content_data do
16+
multi_part_previous_content = { heading: "How to claim for intergalactic travel expenses",
17+
body: "If you or your partner is travelling abroad for more than 7 months, you may be able to claim for expenses." }
18+
multi_part_current_content = { heading: "How to claim for Inter-galactic Travel Expenses",
19+
body: "If you or your partner are travelling abroad for more than 8 months, you may be able to claim for expenses." }
20+
previous_content { multi_part_previous_content }
21+
current_content { multi_part_current_content }
22+
end
923
end
1024
end

spec/models/request_spec.rb

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,68 @@
11
require "rails_helper"
22

3+
RSpec.shared_examples "test JSON content" do |content_field|
4+
context "when #{content_field} contains non string values" do
5+
it "is invalid" do
6+
invalid_hash_content = { "illegal_boolean": false }
7+
record = FactoryBot.build(:request, **{ content_field => invalid_hash_content })
8+
9+
expect(record).not_to be_valid
10+
expect(record.errors.messages[content_field]).to include("value for illegal_boolean must be a string")
11+
end
12+
13+
it "adds an error to #{content_field}" do
14+
invalid_hash_content = "[\"apple\", \"banana\", \"kiwi\"]"
15+
record = FactoryBot.build(:request, **{ content_field => invalid_hash_content })
16+
17+
expect(record).not_to be_valid
18+
expect(record.errors.messages[content_field]).to include("#{content_field} is not a hash")
19+
end
20+
end
21+
end
22+
323
RSpec.describe Request, type: :model do
4-
it "is not valid without required attributes" do
5-
record = described_class.new
24+
context "when missing required attributes" do
25+
it "is invalid" do
26+
record = described_class.new
27+
28+
expect(record).not_to be_valid
29+
end
630

7-
expect(record).not_to be_valid
31+
it "includes errors for each missing required attribute" do
32+
record = described_class.new
33+
34+
expect(record).not_to be_valid
35+
expect(record.errors.attribute_names).to include(:source_id, :source_app, :requester_name, :requester_email, :current_content)
36+
end
837
end
938

10-
it "includes errors for each missing mandatory attribute" do
11-
record = described_class.new
12-
record.valid?
39+
context "when current_content is an empty hash" do
40+
it "raises an error " do
41+
record = FactoryBot.build(:request, current_content: {})
1342

14-
expect(record.errors.attribute_names).to include(:source_id, :source_app, :requester_name, :requester_email, :current_content)
43+
expect(record).not_to be_valid
44+
expect(record.errors.attribute_names).to include(:current_content)
45+
expect(record.errors.messages[:current_content]).to include("can't be blank")
46+
end
1547
end
1648

17-
it "is valid when all required attributes are set" do
18-
record = FactoryBot.build(:request)
49+
include_examples "test JSON content", :current_content
50+
include_examples "test JSON content", :previous_content
1951

20-
expect(record).to be_valid
52+
context "when all required attributes are set" do
53+
it "is valid" do
54+
record = FactoryBot.build(:request)
55+
56+
expect(record).to be_valid
57+
end
58+
end
59+
60+
context "when content hashes contain multiple key-value-pairs" do
61+
it "is valid" do
62+
record = FactoryBot.build(:request, :with_more_complex_content_data)
63+
64+
expect(record).to be_valid
65+
end
2166
end
2267

2368
describe "searching by source_id" do

spec/requests/api/requests_spec.rb

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
source_title: "",
1818
requester_name: "GDS Content Designer",
1919
requester_email: "gds-content-designer@example.com",
20-
current_content: "<p>Test HTML</p>",
21-
previous_content: "",
20+
current_content: {
21+
"heading": "Some title words",
22+
"body": "Many lines of data for the content. Many changes that need fact checking",
23+
},
24+
previous_content: {},
2225
deadline: 1.week.from_now.iso8601,
2326
recipients: ["recipient1@example.com", "recipient2@example.com"],
2427
}
@@ -29,7 +32,7 @@
2932
expect {
3033
post "/api/requests", params: valid_payload, as: :json
3134
}.to change(Request, :count).by(1)
32-
.and change(Collaboration, :count).by(2)
35+
.and change(Collaboration, :count).by(2)
3336

3437
expect(response).to have_http_status(:created)
3538

@@ -39,38 +42,92 @@
3942
request = Request.last
4043
expect(request.source_app).to eq("Mainstream")
4144
expect(request.source_id).to be_present
42-
expect(request.current_content).to eq("<p>Test HTML</p>")
45+
expect(request.current_content["body"]).to eq("Many lines of data for the content. Many changes that need fact checking")
4346
expect(request.status).to eq("new")
4447
expect(request.requester_name).to eq("GDS Content Designer")
4548
expect(request.requester_email).to eq("gds-content-designer@example.com")
4649
end
4750
end
4851

49-
context "with invalid payload" do
52+
context "with an invalid payload" do
53+
let(:base_payload) do
54+
{
55+
source_app: "Mainstream",
56+
source_id: SecureRandom.uuid,
57+
requester_name: "GDS Content Designer",
58+
requester_email: "gds-content-designer@example.com",
59+
current_content: dynamic_current_content,
60+
previous_content: {},
61+
deadline: 1.week.from_now.iso8601,
62+
recipients: ["recipient1@example.com", "recipient2@example.com"],
63+
}
64+
end
65+
5066
it "returns errors for missing required fields" do
51-
invalid_payload = { requester_name: "Alice", recipients: ["recipient1@example.com", "recipient2@example.com"] }
67+
payload_missing_required_fields = { requester_name: "Alice",
68+
recipients: ["recipient1@example.com", "recipient2@example.com"] }
5269

53-
post "/api/requests", params: invalid_payload, as: :json
70+
expect {
71+
post "/api/requests", params: payload_missing_required_fields, as: :json
72+
}.to change(Request, :count).by(0)
73+
.and change(Collaboration, :count).by(0)
5474

55-
expect(response).to have_http_status(:bad_request)
75+
expect(response).to have_http_status(:unprocessable_content)
5676
json = JSON.parse(response.body)
5777
expect(json["errors"]).to include(
5878
"Source can't be blank",
79+
"Source app can't be blank",
5980
"Requester email can't be blank",
81+
"Current content can't be blank",
82+
"Deadline can't be blank",
6083
)
6184
end
62-
end
6385

64-
context "without recipients" do
65-
it "returns a 400 error" do
66-
payload = valid_payload
67-
payload.delete(:recipients)
86+
context "if current_content value is not a string" do
87+
let(:dynamic_current_content) do
88+
{
89+
"normal_field" => "This should pass",
90+
"bad_number_field" => 123,
91+
}
92+
end
93+
94+
it "returns an error" do
95+
post "/api/requests", params: base_payload, as: :json
96+
97+
expect(response).to have_http_status(:unprocessable_content)
98+
json = JSON.parse(response.body)
99+
expect(json["errors"]).to include("Current content value for bad_number_field must be a string")
100+
end
101+
end
102+
103+
context "if current_content contains nested data" do
104+
let(:dynamic_current_content) do
105+
{
106+
"normal_field" => "This should pass",
107+
"sneaky_nested_hash" => { "naughty" => "This should fail" },
108+
}
109+
end
110+
111+
it "returns an error" do
112+
expect {
113+
post "/api/requests", params: base_payload, as: :json
114+
}.to change(Request, :count).by(0)
115+
.and change(Collaboration, :count).by(0)
116+
expect(response).to have_http_status(:unprocessable_content)
117+
end
118+
end
119+
120+
context "without recipients" do
121+
it "returns a 400 error" do
122+
payload = valid_payload
123+
payload.delete(:recipients)
68124

69-
post "/api/requests", params: payload, as: :json
125+
post "/api/requests", params: payload, as: :json
70126

71-
expect(response).to have_http_status(:bad_request)
72-
expect(JSON.parse(response.body)["errors"])
73-
.to include("At least one recipient email is required")
127+
expect(response).to have_http_status(:bad_request)
128+
expect(JSON.parse(response.body)["errors"])
129+
.to include("At least one recipient email is required")
130+
end
74131
end
75132
end
76133
end

0 commit comments

Comments
 (0)