Files
sure/test/controllers/api/v1/users_controller_test.rb
ghost 6e04c6927d feat(imports): add SureImport session batches (#1785)
* feat(imports): add SureImport session batches

Add first-class SureImport sessions for ordered multi-file NDJSON imports.

Persist source mappings across chunks, make session/chunk processing idempotent, expose progress readback, and keep existing single-file import behavior compatible.

Includes the devcontainer libvips runtime dependency needed by ActiveStorage variant tests.

Addresses #1610.

Related to #1458.

* fix(imports): avoid scanner-like API key test data

* test(imports): assert skipped balances are not persisted

* fix(imports): harden session publish retries

Validate expected import chunk sequences exactly before publish, and restore session state with error details when enqueueing the publish job fails.

* fix(imports): close session retry edge cases

Backfill expected chunk counts after client-session insert races and enqueue import-session jobs after the status transition commits. Persist a safe enqueue failure body so API readback does not expose raw queue errors.

* fix(imports): address session publish review gaps

Remove dead transaction external-id assignment, harden session publish retry/sync behavior, align session chunk status docs, and add regression coverage for partial retries and safe enqueue error readback.

* fix(imports): include sessions in family reset

Clear import sessions through the family reset job so chunk imports and source mappings do not survive a reset.

Expose import session and source mapping counts in the reset status response and regenerated OpenAPI schema so polling reflects the full reset surface.

* test(imports): cover split import mapping invariants

* test(imports): cover session verification invariants

* fix(imports): scope SureImport session reimports

* Tighten SureImport session batching

* fix(imports): export rule source ids for sessions

* test(imports): stabilize rule id export assertion

* test(imports): restore reset status session fixture
2026-06-04 11:48:44 +02:00

219 lines
6.7 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read-Only Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
end
# -- Authentication --------------------------------------------------------
test "reset requires authentication" do
delete "/api/v1/users/reset"
assert_response :unauthorized
end
test "destroy requires authentication" do
delete "/api/v1/users/me"
assert_response :unauthorized
end
# -- Scope enforcement -----------------------------------------------------
test "reset requires write scope" do
delete "/api/v1/users/reset", headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "destroy requires write scope" do
delete "/api/v1/users/me", headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
# -- Reset -----------------------------------------------------------------
test "reset requires admin role" do
non_admin_api_key = ApiKey.create!(
user: users(:family_member),
name: "Member Read-Write Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_#{SecureRandom.hex(8)}"
)
assert_no_enqueued_jobs only: FamilyResetJob do
delete "/api/v1/users/reset", headers: api_headers(non_admin_api_key)
end
assert_response :forbidden
body = JSON.parse(response.body)
assert_equal "You are not authorized to perform this action", body["message"]
end
test "reset enqueues FamilyResetJob and returns 200" do
assert_enqueued_with(job: FamilyResetJob, args: [ @user.family ]) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :ok
body = JSON.parse(response.body)
assert_equal "Account reset has been initiated", body["message"]
assert_equal "queued", body["status"]
assert_equal @user.family.id, body["family_id"]
assert body["job_id"].present?
assert_equal "/api/v1/users/reset/status", body["status_url"]
end
test "reset returns controlled error when enqueue fails" do
FamilyResetJob.stub(:perform_later, ->(_family) { raise StandardError, "queue down" }) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :internal_server_error
body = JSON.parse(response.body)
assert_equal "reset_enqueue_failed", body["error"]
assert_equal "Account reset could not be queued", body["message"]
assert_not_includes response.body, "queue down"
end
test "reset status requires authentication" do
get "/api/v1/users/reset/status"
assert_response :unauthorized
end
test "reset status requires admin role" do
non_admin_api_key = ApiKey.create!(
user: users(:family_member),
name: "Member Read Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_member_read_#{SecureRandom.hex(8)}"
)
get "/api/v1/users/reset/status", headers: api_headers(non_admin_api_key)
assert_response :forbidden
end
test "reset status returns family data counts" do
import_session = @user.family.import_sessions.create!(expected_chunks: 1)
import_session.imports.create!(
family: @user.family,
type: "SureImport",
sequence: 1,
checksum: "a" * 64
)
import_session.source_mappings.create!(
family: @user.family,
source_type: "Category",
source_id: "source-category-1",
target: @user.family.categories.first
)
get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key)
assert_response :ok
body = JSON.parse(response.body)
assert_equal @user.family.id, body["family_id"]
assert_includes %w[complete data_remaining], body["status"]
assert_equal body["counts"].values.sum.zero?, body["reset_complete"]
assert_equal expected_reset_count_keys.sort, body["counts"].keys.sort
assert_equal 1, body["counts"]["import_sessions"]
assert_equal 1, body["counts"]["import_source_mappings"]
end
test "reset status ignores the follow-up family sync after reset" do
family = @user.family
Provider::Registry.stubs(:plaid_provider_for_region).returns(nil)
Family::FinancialDataReset.new(family: family, dry_run: false, confirmed: true).call
family.syncs.create!
get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key)
assert_response :ok
body = JSON.parse(response.body)
assert_equal "complete", body["status"]
assert_equal true, body["reset_complete"]
assert_not body["counts"].key?("syncs")
end
# -- Delete account --------------------------------------------------------
test "destroy deactivates user and returns 200" do
solo_family = Family.create!(name: "Solo Family", currency: "USD", locale: "en", date_format: "%m-%d-%Y")
solo_user = solo_family.users.create!(
email: "solo@example.com",
password: "password123",
password_confirmation: "password123",
role: :admin
)
solo_api_key = ApiKey.create!(
user: solo_user,
name: "Solo Key",
scopes: [ "read_write" ],
source: "web",
display_key: "test_solo_#{SecureRandom.hex(8)}"
)
delete "/api/v1/users/me", headers: api_headers(solo_api_key)
assert_response :ok
body = JSON.parse(response.body)
assert_equal "Account has been deleted", body["message"]
solo_user.reload
assert_not solo_user.active?
assert_not_equal "solo@example.com", solo_user.email
end
test "destroy returns 422 when admin has other family members" do
delete "/api/v1/users/me", headers: api_headers(@api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Failed to delete account", body["error"]
end
# -- Deactivated user ------------------------------------------------------
test "rejects deactivated user with 401" do
@user.update_column(:active, false)
delete "/api/v1/users/reset", headers: api_headers(@api_key)
assert_response :unauthorized
body = JSON.parse(response.body)
assert_equal "Account has been deactivated", body["message"]
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
def expected_reset_count_keys
Family::FinancialDataReset::STATUS_COUNT_KEYS.map(&:to_s)
end
end