From e16cc21b0ad614a223d43edca1717e76e2b8f8d4 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 1 Dec 2025 10:10:51 +0000 Subject: [PATCH] Add "data export" feature - Adds a button in Account Settings where you can request a ZIP export of your Fizzy data - Export files are created in the background. When ready, a link to download them is sent to the requester. - Exports expire after 24 hours. And are limited to 10 per day. --- Gemfile | 1 + Gemfile.lock | 1 + Gemfile.saas.lock | 1 + app/controllers/account/exports_controller.rb | 24 +++++ .../controllers/auto_click_controller.js | 7 ++ app/jobs/export_account_data_job.rb | 7 ++ app/mailers/export_mailer.rb | 8 ++ app/models/account.rb | 1 + app/models/account/export.rb | 75 +++++++++++++++ app/models/card.rb | 2 +- app/models/card/exportable.rb | 76 +++++++++++++++ app/models/user.rb | 1 + app/views/account/exports/show.html.erb | 37 ++++++++ app/views/account/settings/_export.html.erb | 22 +++++ app/views/account/settings/show.html.erb | 1 + .../mailers/export_mailer/completed.html.erb | 6 ++ .../mailers/export_mailer/completed.text.erb | 3 + config/recurring.yml | 3 + config/routes.rb | 1 + .../20251201100607_create_account_exports.rb | 14 +++ db/schema.rb | 13 ++- db/schema_sqlite.rb | 13 ++- .../accounts/exports_controller_test.rb | 78 +++++++++++++++ test/fixtures/account/exports.yml | 12 +++ test/mailers/export_mailer_test.rb | 16 ++++ .../mailers/previews/export_mailer_preview.rb | 11 +++ test/models/account/export_test.rb | 95 +++++++++++++++++++ test/models/card/exportable_test.rb | 39 ++++++++ 28 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 app/controllers/account/exports_controller.rb create mode 100644 app/javascript/controllers/auto_click_controller.js create mode 100644 app/jobs/export_account_data_job.rb create mode 100644 app/mailers/export_mailer.rb create mode 100644 app/models/account/export.rb create mode 100644 app/models/card/exportable.rb create mode 100644 app/views/account/exports/show.html.erb create mode 100644 app/views/account/settings/_export.html.erb create mode 100644 app/views/mailers/export_mailer/completed.html.erb create mode 100644 app/views/mailers/export_mailer/completed.text.erb create mode 100644 db/migrate/20251201100607_create_account_exports.rb create mode 100644 test/controllers/accounts/exports_controller_test.rb create mode 100644 test/fixtures/account/exports.yml create mode 100644 test/mailers/export_mailer_test.rb create mode 100644 test/mailers/previews/export_mailer_preview.rb create mode 100644 test/models/account/export_test.rb create mode 100644 test/models/card/exportable_test.rb diff --git a/Gemfile b/Gemfile index 63c98f89c..7f7a463bb 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,7 @@ gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" +gem "rubyzip", require: "zip" gem "mittens" gem "useragent", bc: "useragent" diff --git a/Gemfile.lock b/Gemfile.lock index 502c14965..5eefe452a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -503,6 +503,7 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase + rubyzip selenium-webdriver solid_cable (>= 3.0) solid_cache (~> 1.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f1c1b3b98..698763f7c 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -632,6 +632,7 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase + rubyzip selenium-webdriver sentry-rails sentry-ruby diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb new file mode 100644 index 000000000..13d000002 --- /dev/null +++ b/app/controllers/account/exports_controller.rb @@ -0,0 +1,24 @@ +class Account::ExportsController < ApplicationController + before_action :ensure_export_limit_not_exceeded, only: :create + before_action :set_export, only: :show + + CURRENT_EXPORT_LIMIT = 10 + + def show + end + + def create + Current.account.exports.create!(user: Current.user).build_later + + redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." + end + + private + def ensure_export_limit_not_exceeded + head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT + end + + def set_export + @export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user) + end +end diff --git a/app/javascript/controllers/auto_click_controller.js b/app/javascript/controllers/auto_click_controller.js new file mode 100644 index 000000000..ebc6722b2 --- /dev/null +++ b/app/javascript/controllers/auto_click_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.click() + } +} diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_account_data_job.rb new file mode 100644 index 000000000..82b5071cb --- /dev/null +++ b/app/jobs/export_account_data_job.rb @@ -0,0 +1,7 @@ +class ExportAccountDataJob < ApplicationJob + queue_as :backend + + def perform(export) + export.build + end +end diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb new file mode 100644 index 000000000..62def6931 --- /dev/null +++ b/app/mailers/export_mailer.rb @@ -0,0 +1,8 @@ +class ExportMailer < ApplicationMailer + def completed(export) + @export = export + @user = export.user + + mail to: @user.identity.email_address, subject: "Your Fizzy export is ready" + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 9c445f10e..01f4d1d8f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -8,6 +8,7 @@ class Account < ApplicationRecord has_many :webhooks, dependent: :destroy has_many :tags, dependent: :destroy has_many :columns, dependent: :destroy + has_many :exports, class_name: "Account::Export", dependent: :destroy has_many_attached :uploads diff --git a/app/models/account/export.rb b/app/models/account/export.rb new file mode 100644 index 000000000..289c0352f --- /dev/null +++ b/app/models/account/export.rb @@ -0,0 +1,75 @@ +class Account::Export < ApplicationRecord + belongs_to :account + belongs_to :user + + has_one_attached :file + + enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + + scope :current, -> { where(created_at: 24.hours.ago..) } + scope :expired, -> { where(completed_at: ...24.hours.ago) } + + def self.cleanup + expired.destroy_all + end + + def build_later + ExportAccountDataJob.perform_later(self) + end + + def build + processing! + + zipfile = generate_zip + file.attach( + io: File.open(zipfile.path), + filename: "export-#{id}.zip", + content_type: "application/zip" + ) + + mark_completed + ExportMailer.completed(self).deliver_later + rescue => e + update!(status: :failed) + raise + ensure + zipfile&.close + zipfile&.unlink + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + private + def generate_zip + Tempfile.new([ "export", ".zip" ]).tap do |tempfile| + Zip::File.open(tempfile.path, create: true) do |zip| + exportable_cards.find_each do |card| + add_card_to_zip(zip, card) + end + end + end + end + + def exportable_cards + user.accessible_cards.includes( + :board, + creator: :identity, + comments: { creator: :identity }, + rich_text_description: { embeds_attachments: :blob } + ) + end + + def add_card_to_zip(zip, card) + zip.get_output_stream("#{card.number}.json") do |f| + f.write(card.export_json) + end + + card.export_attachments.each do |attachment| + zip.get_output_stream(attachment[:path]) do |f| + attachment[:blob].download { |chunk| f.write(chunk) } + end + end + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 0a589d0b7..08787b3f3 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,6 @@ class Card < ApplicationRecord include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } diff --git a/app/models/card/exportable.rb b/app/models/card/exportable.rb new file mode 100644 index 000000000..dc437c296 --- /dev/null +++ b/app/models/card/exportable.rb @@ -0,0 +1,76 @@ +module Card::Exportable + extend ActiveSupport::Concern + include ActionView::Helpers::TagHelper + + def export_json + { + number: number, + title: title, + board: board.name, + status: export_status, + creator: export_user(creator), + description: export_html(description), + created_at: created_at.iso8601, + updated_at: updated_at.iso8601, + comments: comments.chronologically.map do |comment| + { + id: comment.id, + body: export_html(comment.body), + creator: export_user(comment.creator), + created_at: comment.created_at.iso8601 + } + end + }.to_json + end + + def export_attachments + collect_attachments.map do |attachment| + { path: export_attachment_path(attachment.blob), blob: attachment.blob } + end + end + + private + def export_html(rich_text) + return "" if rich_text.blank? + + rich_text.body.render_attachments do |attachment| + blob = attachment.attachable + path = export_attachment_path(blob) + + if blob.image? + tag.img(src: path, alt: blob.filename) + else + tag.a(blob.filename, href: path) + end + end.to_html + end + + def export_user(user) + { + id: user.id, + name: user.name, + email: user.identity&.email_address + } + end + + def export_attachment_path(blob) + "#{number}/#{blob.key}_#{blob.filename}" + end + + def collect_attachments + attachments.to_a + comments.flat_map { |c| c.attachments.to_a } + end + + def export_status + case + when closed? + "Done" + when postponed? + "Not now" + when column.present? + column.name + else + "Maybe?" + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 90aab0338..68df97a65 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User < ApplicationRecord has_many :closures, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card + has_many :exports, class_name: "Account::Export", dependent: :destroy scope :with_avatars, -> { preload(:account, :avatar_attachment) } diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb new file mode 100644 index 000000000..5a3defa5a --- /dev/null +++ b/app/views/account/exports/show.html.erb @@ -0,0 +1,37 @@ +<% if @export.present? %> + <% @page_title = "Download Export" %> +<% else %> + <% @page_title = "Download Expired" %> +<% end %> + +<% content_for :header do %> +
+ <%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + + <%= icon_tag "arrow-left" %> + Back to Account Settings + + + <% end %> +
+<% end %> + +
+
+

<%= @page_title %>

+
+ + <% if @export.present? %> +

Your export is ready. The download should start automatically.

+ + <%= link_to rails_blob_path(@export.file, disposition: "attachment"), + id: "download-link", + class: "btn btn--primary", + data: { turbo: false, controller: "auto-click" } do %> + Download Export + <% end %> + <% else %> +

Your download link has expired. You'll need to request a new export.

+ <% end %> + +
diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb new file mode 100644 index 000000000..e5c2a9c02 --- /dev/null +++ b/app/views/account/settings/_export.html.erb @@ -0,0 +1,22 @@ +
+
+

Data Export

+

You can request a ZIP file containing all your Fizzy data.

+
+ +
+ + + +

Data Export

+

We will create a downloadable ZIP file of all the Fizzy data that you have access to in this account.

+

This can take a few minutes. When the file is ready, we'll email you a link to download it.

+

The download link will expire after 24 hours. But you can always request another export later.

+ +
+ + <%= button_to "Export", account_exports_path, method: :post, class: "btn btn--primary", form: { data: { action: "submit->dialog#close" } } %> +
+
+
+
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 57f3913bf..4291dc1b4 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -16,4 +16,5 @@ <%= render "account/settings/entropy", account: @account %> + <%= render "account/settings/export" %> diff --git a/app/views/mailers/export_mailer/completed.html.erb b/app/views/mailers/export_mailer/completed.html.erb new file mode 100644 index 000000000..d95aa9d69 --- /dev/null +++ b/app/views/mailers/export_mailer/completed.html.erb @@ -0,0 +1,6 @@ +

Your export is ready

+

Your Fizzy data export has finished processing and is ready to download.

+ +

<%= link_to "Download your export", account_export_url(@export) %>

+ + diff --git a/app/views/mailers/export_mailer/completed.text.erb b/app/views/mailers/export_mailer/completed.text.erb new file mode 100644 index 000000000..bfa633a5a --- /dev/null +++ b/app/views/mailers/export_mailer/completed.text.erb @@ -0,0 +1,3 @@ +Your Fizzy data export has finished processing and is ready to download. + +Download your export: <%= account_export_url(@export) %> diff --git a/config/recurring.yml b/config/recurring.yml index 2afc11c85..f59f97ead 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -27,6 +27,9 @@ production: &production cleanup_magic_links: command: "MagicLink.cleanup" schedule: every 4 hours + cleanup_exports: + command: "Account::Export.cleanup" + schedule: every hour at minute 20 <% if Fizzy.saas? %> # Metrics diff --git a/config/routes.rb b/config/routes.rb index 6d13e219b..bbce4fe01 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,7 @@ Rails.application.routes.draw do resource :join_code resource :settings resource :entropy + resources :exports, only: [ :create, :show ] end resources :users do diff --git a/db/migrate/20251201100607_create_account_exports.rb b/db/migrate/20251201100607_create_account_exports.rb new file mode 100644 index 000000000..9638840c1 --- /dev/null +++ b/db/migrate/20251201100607_create_account_exports.rb @@ -0,0 +1,14 @@ +class CreateAccountExports < ActiveRecord::Migration[8.2] + def change + create_table :account_exports, id: :uuid do |t| + t.uuid :account_id, null: false + t.uuid :user_id, null: false + t.string :status, default: "pending", null: false + t.datetime :completed_at + t.timestamps + + t.index :account_id + t.index :user_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 52a460e71..b84d3c160 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do +ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,17 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_account_exports_on_account_id" + t.index ["user_id"], name: "index_account_exports_on_user_id" + end + create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1a4a85e1d..6f6d6cf53 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do +ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,17 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_exports", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", limit: 255, default: "pending", null: false + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_account_exports_on_account_id" + t.index ["user_id"], name: "index_account_exports_on_user_id" + end + create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb new file mode 100644 index 000000000..4806b65f0 --- /dev/null +++ b/test/controllers/accounts/exports_controller_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class Account::ExportsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + end + + test "create creates an export record and enqueues job" do + assert_difference -> { Account::Export.count }, 1 do + assert_enqueued_with(job: ExportAccountDataJob) do + post account_exports_path + end + end + + assert_redirected_to account_settings_path + assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice] + end + + test "create associates export with current user" do + post account_exports_path + + export = Account::Export.last + assert_equal users(:david), export.user + assert_equal Current.account, export.account + assert export.pending? + end + + test "create rejects request when current export limit is reached" do + Account::ExportsController::CURRENT_EXPORT_LIMIT.times do + Account::Export.create!(account: Current.account, user: users(:david)) + end + + assert_no_difference -> { Account::Export.count } do + post account_exports_path + end + + assert_response :too_many_requests + end + + test "create allows request when exports are older than one day" do + Account::ExportsController::CURRENT_EXPORT_LIMIT.times do + Account::Export.create!(account: Current.account, user: users(:david), created_at: 2.days.ago) + end + + assert_difference -> { Account::Export.count }, 1 do + post account_exports_path + end + + assert_redirected_to account_settings_path + end + + test "show displays completed export with download link" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + export.build + + get account_export_path(export) + + assert_response :success + assert_select "a#download-link" + end + + test "show displays a warning if the export is missing" do + get account_export_path("not-really-an-export") + + assert_response :success + assert_select "h2", "Download Expired" + end + + test "show does not allow access to another user's export" do + export = Account::Export.create!(account: Current.account, user: users(:kevin)) + export.build + + get account_export_path(export) + + assert_response :success + assert_select "h2", "Download Expired" + end +end diff --git a/test/fixtures/account/exports.yml b/test/fixtures/account/exports.yml new file mode 100644 index 000000000..764f8aada --- /dev/null +++ b/test/fixtures/account/exports.yml @@ -0,0 +1,12 @@ +pending_export: + id: <%= ActiveRecord::FixtureSet.identify("pending_export", :uuid) %> + account: 37s_uuid + user: david_uuid + status: pending + +completed_export: + id: <%= ActiveRecord::FixtureSet.identify("completed_export", :uuid) %> + account: 37s_uuid + user: david_uuid + status: completed + completed_at: <%= 1.hour.ago.to_fs(:db) %> diff --git a/test/mailers/export_mailer_test.rb b/test/mailers/export_mailer_test.rb new file mode 100644 index 000000000..a627d615c --- /dev/null +++ b/test/mailers/export_mailer_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class ExportMailerTest < ActionMailer::TestCase + test "completed" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + email = ExportMailer.completed(export) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ "david@37signals.com" ], email.to + assert_equal "Your Fizzy export is ready", email.subject + assert_match %r{/exports/#{export.id}}, email.body.encoded + end +end diff --git a/test/mailers/previews/export_mailer_preview.rb b/test/mailers/previews/export_mailer_preview.rb new file mode 100644 index 000000000..3a9ce0113 --- /dev/null +++ b/test/mailers/previews/export_mailer_preview.rb @@ -0,0 +1,11 @@ +class ExportMailerPreview < ActionMailer::Preview + def completed + export = Account::Export.new( + id: "preview-export-id", + account: Account.first, + user: User.first + ) + + ExportMailer.completed(export) + end +end diff --git a/test/models/account/export_test.rb b/test/models/account/export_test.rb new file mode 100644 index 000000000..37b0ea47e --- /dev/null +++ b/test/models/account/export_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class Account::ExportTest < ActiveSupport::TestCase + test "build_later enqueues ExportAccountDataJob" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do + export.build_later + end + end + + test "build generates zip with card JSON files" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert export.file.attached? + assert_equal "application/zip", export.file.content_type + end + + test "build sets status to processing then completed" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert_not_nil export.completed_at + end + + test "build sends email when completed" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do + export.build + end + end + + test "build sets status to failed on error" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + export.stubs(:generate_zip).raises(StandardError.new("Test error")) + + assert_raises(StandardError) do + export.build + end + + assert export.failed? + end + + test "cleanup deletes exports completed more than 24 hours ago" do + old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) + recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) + pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending) + + Account::Export.cleanup + + assert_not Account::Export.exists?(old_export.id) + assert Account::Export.exists?(recent_export.id) + assert Account::Export.exists?(pending_export.id) + end + + test "build includes only accessible cards for user" do + user = users(:david) + export = Account::Export.create!(account: Current.account, user: user) + + export.build + + assert export.completed? + assert export.file.attached? + + # Verify zip contents + Tempfile.create([ "test", ".zip" ]) do |temp| + temp.binmode + export.file.download { |chunk| temp.write(chunk) } + temp.rewind + + Zip::File.open(temp.path) do |zip| + json_files = zip.glob("*.json") + assert json_files.any?, "Zip should contain at least one JSON file" + + # Verify structure of a JSON file + json_content = JSON.parse(zip.read(json_files.first.name)) + assert json_content.key?("number") + assert json_content.key?("title") + assert json_content.key?("board") + assert json_content.key?("creator") + assert json_content["creator"].key?("id") + assert json_content["creator"].key?("name") + assert json_content["creator"].key?("email") + assert json_content.key?("description") + assert json_content.key?("comments") + end + end + end +end diff --git a/test/models/card/exportable_test.rb b/test/models/card/exportable_test.rb new file mode 100644 index 000000000..080ed9948 --- /dev/null +++ b/test/models/card/exportable_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Card::ExportableTest < ActiveSupport::TestCase + test "export_json returns card data as JSON" do + card = cards(:logo) + + json = JSON.parse(card.export_json) + + assert_equal 1, json["number"] + assert_equal "The logo isn't big enough", json["title"] + assert_equal "Writebook", json["board"] + assert_equal "Triage", json["status"] + assert_equal users(:david).id, json["creator"]["id"] + assert_equal "David", json["creator"]["name"] + assert_equal "david@37signals.com", json["creator"]["email"] + assert_equal "", json["description"] + assert_equal 5, json["comments"].count + assert_equal card.created_at.iso8601, json["created_at"] + assert_equal card.updated_at.iso8601, json["updated_at"] + end + + test "export_attachments returns attachment paths and blobs" do + card = cards(:logo) + + blob = ActiveStorage::Blob.create_and_upload!( + io: file_fixture("moon.jpg").open, + filename: "moon.jpg", + content_type: "image/jpeg" + ) + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + card.update!(description: "

Here is an image:

#{attachment_html}") + + attachments = card.export_attachments + + assert_equal 1, attachments.count + assert_equal file_fixture("moon.jpg").binread, attachments.first[:blob].download + assert attachments.first[:path].start_with?("#{card.number}/") + end +end