• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / pdc_describe / a13315e3-e13e-4c5f-a778-a1981980f07d

15 May 2024 01:56PM UTC coverage: 95.871% (-0.04%) from 95.909%
a13315e3-e13e-4c5f-a778-a1981980f07d

Pull #1813

circleci

jrgriffiniii
Ensures that one and only one README file is uploaded and that there is one or more files uploaded for any given Work
Pull Request #1813: [wip] Ensures that one and only one README file is uploaded and that there is one or more files uploaded for any given Work

21 of 22 new or added lines in 4 files covered. (95.45%)

1 existing line in 1 file now uncovered.

3274 of 3415 relevant lines covered (95.87%)

223.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.34
/app/models/work.rb
1
# frozen_string_literal: true
2

3
# rubocop:disable Metrics/ClassLength
4
class Work < ApplicationRecord
1✔
5
  # Errors for cases where there is no valid Group
6
  class InvalidGroupError < ::ArgumentError; end
1✔
7

8
  has_many :work_activity, -> { order(updated_at: :desc) }, dependent: :destroy
294✔
9
  has_many :user_work, -> { order(updated_at: :desc) }, dependent: :destroy
10✔
10
  has_many :upload_snapshots, -> { order(updated_at: :desc) }, dependent: :destroy
317✔
11

12
  belongs_to :group, class_name: "Group"
1✔
13
  belongs_to :curator, class_name: "User", foreign_key: "curator_user_id", optional: true
1✔
14

15
  attribute :work_type, :string, default: "DATASET"
1✔
16
  attribute :profile, :string, default: "DATACITE"
1✔
17

18
  attr_accessor :user_entered_doi
1✔
19

20
  alias state_history user_work
1✔
21

22
  delegate :valid_to_submit, :valid_to_draft, :valid_to_approve, :valid_to_complete, to: :work_validator
1✔
23

24
  include AASM
1✔
25

26
  aasm column: :state do
1✔
27
    state :none, initial: true
1✔
28
    state :draft, :awaiting_approval, :approved, :withdrawn, :deletion_marker
1✔
29

30
    event :draft, after: :draft_doi do
1✔
31
      transitions from: :none, to: :draft, guard: :valid_to_draft
1✔
32
    end
33

34
    event :complete_submission do
1✔
35
      transitions from: :draft, to: :awaiting_approval, guard: :valid_to_complete
1✔
36
    end
37

38
    event :request_changes do
1✔
39
      transitions from: :awaiting_approval, to: :awaiting_approval, guard: :valid_to_submit
1✔
40
    end
41

42
    event :approve do
1✔
43
      transitions from: :awaiting_approval, to: :approved, guard: :valid_to_approve, after: :publish
1✔
44
    end
45

46
    event :withdraw do
1✔
47
      transitions from: [:draft, :awaiting_approval, :approved], to: :withdrawn
1✔
48
    end
49

50
    event :resubmit do
1✔
51
      transitions from: :withdrawn, to: :draft
1✔
52
    end
53

54
    event :remove do
1✔
55
      transitions from: :withdrawn, to: :deletion_marker
1✔
56
    end
57

58
    after_all_events :track_state_change
1✔
59
  end
60

61
  def state=(new_state)
1✔
62
    new_state_sym = new_state.to_sym
736✔
63
    valid_states = self.class.aasm.states.map(&:name)
736✔
64
    raise(StandardError, "Invalid state '#{new_state}'") unless valid_states.include?(new_state_sym)
736✔
65
    aasm_write_state_without_persistence(new_state_sym)
735✔
66
  end
67

68
  ##
69
  # Is this work editable by a given user?
70
  # A work is editable when:
71
  # * it is being edited by the person who made it
72
  # * it is being edited by a group admin of the group where is resides
73
  # * it is being edited by a super admin
74
  # @param [User]
75
  # @return [Boolean]
76
  def editable_by?(user)
1✔
77
    submitted_by?(user) || administered_by?(user)
381✔
78
  end
79

80
  def editable_in_current_state?(user)
1✔
81
    # anyone with edit privleges can edit a work while it is in draft
82
    return editable_by?(user) if draft?
254✔
83

84
    # Only admisitrators can edit a work in other states
85
    administered_by?(user)
51✔
86
  end
87

88
  def submitted_by?(user)
1✔
89
    created_by_user_id == user.id
381✔
90
  end
91

92
  def administered_by?(user)
1✔
93
    user.has_role?(:group_admin, group)
102✔
94
  end
95

96
  class << self
1✔
97
    def find_by_doi(doi)
1✔
98
      prefix = "10.34770/"
48✔
99
      doi = "#{prefix}#{doi}" unless doi.blank? || doi.start_with?(prefix)
48✔
100
      Work.find_by!("metadata @> ?", JSON.dump(doi:))
48✔
101
    end
102

103
    def find_by_ark(ark)
1✔
104
      prefix = "ark:/"
324✔
105
      ark = "#{prefix}#{ark}" unless ark.blank? || ark.start_with?(prefix)
324✔
106
      Work.find_by!("metadata @> ?", JSON.dump(ark:))
324✔
107
    end
108

109
    delegate :resource_type_general_values, to: PDCMetadata::Resource
1✔
110
  end
111

112
  include Rails.application.routes.url_helpers
1✔
113

114
  before_save do |work|
1✔
115
    # Ensure that the metadata JSONB postgres field is persisted properly
116
    work.metadata = JSON.parse(work.resource.to_json)
1,186✔
117
  end
118

119
  after_save do |work|
1✔
120
    if work.approved?
1,185✔
121
      work.reload
132✔
122
    end
123
  end
124

125
  validate do |_work|
1✔
126
    work_validator.valid?
1,239✔
127
  end
128

129
  # Overload ActiveRecord.reload method
130
  # https://apidock.com/rails/ActiveRecord/Base/reload
131
  #
132
  # NOTE: Usually `after_save` is a better place to put this kind of code:
133
  #
134
  #   after_save do |work|
135
  #     work.resource = nil
136
  #   end
137
  #
138
  # but that does not work in this case because the block points to a different
139
  # memory object for `work` than the we want we want to reload.
140
  def reload(options = nil)
1✔
141
    super
241✔
142
    # Force `resource` to be reloaded
143
    @resource = nil
241✔
144
    self
241✔
145
  end
146

147
  def title
1✔
148
    resource.main_title
529✔
149
  end
150

151
  def uploads_attributes
1✔
152
    return [] if approved? # once approved we no longer allow the updating of uploads via the application
113✔
153
    uploads.map do |upload|
107✔
154
      {
155
        id: upload.id,
38✔
156
        key: upload.key,
157
        filename: upload.filename.to_s,
158
        created_at: upload.created_at,
159
        url: upload.url
160
      }
161
    end
162
  end
163

164
  def form_attributes
1✔
165
    {
166
      uploads: uploads_attributes
113✔
167
    }
168
  end
169

170
  def draft_doi
1✔
171
    return if resource.doi.present?
52✔
172
    resource.doi = datacite_service.draft_doi
26✔
173
    save!
24✔
174
  end
175

176
  # Return the DOI formatted as a URL, so it can be used as a link on display pages
177
  # @return [String] A url formatted version of the DOI
178
  def doi_url
1✔
179
    return "https://doi.org/#{doi}" unless doi.starts_with?("https://doi.org")
1✔
180
    doi
×
181
  end
182

183
  def created_by_user
1✔
184
    User.find(created_by_user_id)
482✔
185
  rescue ActiveRecord::RecordNotFound
186
    nil
1✔
187
  end
188

189
  def resource=(resource)
1✔
190
    @resource = resource
946✔
191
    # Ensure that the metadata JSONB postgres field is persisted properly
192
    self.metadata = JSON.parse(resource.to_json)
946✔
193
  end
194

195
  def resource
1✔
196
    @resource ||= PDCMetadata::Resource.new_from_jsonb(metadata)
29,735✔
197
  end
198

199
  def url
1✔
200
    return unless persisted?
×
201

202
    @url ||= url_for(self)
×
203
  end
204

205
  def files_location_upload?
1✔
206
    files_location.blank? || files_location == "file_upload"
15✔
207
  end
208

209
  def files_location_cluster?
1✔
210
    files_location == "file_cluster"
87✔
211
  end
212

213
  def files_location_other?
1✔
214
    files_location == "file_other"
102✔
215
  end
216

217
  def change_curator(curator_user_id, current_user)
1✔
218
    if curator_user_id == "no-one"
6✔
219
      clear_curator(current_user)
1✔
220
    else
221
      update_curator(curator_user_id, current_user)
5✔
222
    end
223
  end
224

225
  def clear_curator(current_user)
1✔
226
    # Update the curator on the Work
227
    self.curator_user_id = nil
2✔
228
    save!
2✔
229

230
    # ...and log the activity
231
    WorkActivity.add_work_activity(id, "Unassigned existing curator", current_user.id, activity_type: WorkActivity::SYSTEM)
2✔
232
  end
233

234
  def update_curator(curator_user_id, current_user)
1✔
235
    # Update the curator on the Work
236
    self.curator_user_id = curator_user_id
6✔
237
    save!
6✔
238

239
    # ...and log the activity
240
    new_curator = User.find(curator_user_id)
5✔
241

242
    work_url = "[#{title}](#{Rails.application.routes.url_helpers.work_url(self)})"
5✔
243

244
    # Troubleshooting https://github.com/pulibrary/pdc_describe/issues/1783
245
    if work_url.include?("/describe/describe/")
5✔
246
      Rails.logger.error("URL #{work_url} included /describe/describe/ and was fixed. See https://github.com/pulibrary/pdc_describe/issues/1783")
×
247
      work_url = work_url.gsub("/describe/describe/", "/describe/")
×
248
    end
249

250
    message = if curator_user_id.to_i == current_user.id
5✔
251
                "Self-assigned @#{current_user.uid} as curator for work #{work_url}"
2✔
252
              else
253
                "Set curator to @#{new_curator.uid} for work #{work_url}"
3✔
254
              end
255
    WorkActivity.add_work_activity(id, message, current_user.id, activity_type: WorkActivity::SYSTEM)
5✔
256
  end
257

258
  def add_message(message, current_user_id)
1✔
259
    WorkActivity.add_work_activity(id, message, current_user_id, activity_type: WorkActivity::MESSAGE)
11✔
260
  end
261

262
  def add_provenance_note(date, note, current_user_id, change_label = "")
1✔
263
    WorkActivity.add_work_activity(id, { note:, change_label: }.to_json, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
29✔
264
    # WorkActivity.add_work_activity(id, note, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
265
  end
266

267
  def log_changes(resource_compare, current_user_id)
1✔
268
    return if resource_compare.identical?
70✔
269
    WorkActivity.add_work_activity(id, resource_compare.differences.to_json, current_user_id, activity_type: WorkActivity::CHANGES)
56✔
270
  end
271

272
  def log_file_changes(current_user_id)
1✔
273
    return if changes.count == 0
19✔
274
    WorkActivity.add_work_activity(id, changes.to_json, current_user_id, activity_type: WorkActivity::FILE_CHANGES)
19✔
275
  end
276

277
  def activities
1✔
278
    WorkActivity.activities_for_work(id, WorkActivity::MESSAGE_ACTIVITY_TYPES + WorkActivity::CHANGE_LOG_ACTIVITY_TYPES)
117✔
279
  end
280

281
  def new_notification_count_for_user(user_id)
1✔
282
    WorkActivityNotification.joins(:work_activity)
121✔
283
                            .where(user_id:, read_at: nil)
284
                            .where(work_activity: { work_id: id })
285
                            .count
286
  end
287

288
  # Marks as read the notifications for the given user_id in this work.
289
  # In practice, the user_id is the id of the current user and therefore this method marks the current's user
290
  # notifications as read.
291
  def mark_new_notifications_as_read(user_id)
1✔
292
    activities.each do |activity|
115✔
293
      unread_notifications = WorkActivityNotification.where(user_id:, work_activity_id: activity.id, read_at: nil)
128✔
294
      unread_notifications.each do |notification|
128✔
295
        notification.read_at = Time.now.utc
35✔
296
        notification.save
35✔
297
      end
298
    end
299
  end
300

301
  def current_transition
1✔
302
    aasm.current_event.to_s.humanize.delete("!")
18✔
303
  end
304

305
  # Retrieve the S3 file uploads associated with the Work
306
  # @return [Array<S3File>]
307
  def uploads
1✔
308
    return post_curation_uploads if approved?
242✔
309

310
    pre_curation_uploads
232✔
311
  end
312

313
  # Retrieve the S3 file uploads named "README"
314
  # @return [Array<S3File>]
315
  def readme_uploads
1✔
316
    uploads.select { |s3_file| s3_file.filename.include?("README") }
207✔
317
  end
318

319
  # Retrieve the S3 file uploads which are research artifacts proper (not README or other files providing metadata/documentation)
320
  # @return [Array<S3File>]
321
  def artifact_uploads
1✔
NEW
322
    uploads.reject { |s3_file| s3_file.filename.include?("README") }
×
323
  end
324

325
  # Returns the list of files for the work with some basic information about each of them.
326
  # This method is much faster than `uploads` because it does not return the actual S3File
327
  # objects to the client, instead it returns just a few selected data elements.
328
  def file_list
1✔
329
    s3_files = approved? ? post_curation_uploads : pre_curation_uploads
272✔
330
    files_info = s3_files.map do |s3_file|
272✔
331
      {
332
        "safe_id": s3_file.safe_id,
166✔
333
        "filename": s3_file.filename,
334
        "filename_display": s3_file.filename_display,
335
        "last_modified": s3_file.last_modified,
336
        "last_modified_display": s3_file.last_modified_display,
337
        "size": s3_file.size,
338
        "display_size": s3_file.display_size,
339
        "url": s3_file.url
340
      }
341
    end
342
    files_info
272✔
343
  end
344

345
  def total_file_size
1✔
346
    @total_file_size ||= begin
128✔
347
      total_size = 0
116✔
348
      file_list.each do |file|
116✔
349
        total_size += file[:size]
64✔
350
      end
351
      total_size
116✔
352
    end
353
  end
354

355
  # Fetches the data from S3 directly bypassing ActiveStorage
356
  def pre_curation_uploads
1✔
357
    s3_query_service.client_s3_files.sort_by(&:filename)
655✔
358
  end
359

360
  # Accesses post-curation S3 Bucket Objects
361
  def post_curation_s3_resources
1✔
362
    if approved?
74✔
363
      s3_resources
46✔
364
    else
365
      []
28✔
366
    end
367
  end
368

369
  # Returns the files in post-curation for the work
370
  def post_curation_uploads(force_post_curation: false)
1✔
371
    if force_post_curation
79✔
372
      # Always use the post-curation data regardless of the work's status
373
      post_curation_s3_query_service = S3QueryService.new(self, "postcuration")
5✔
374
      post_curation_s3_query_service.data_profile.fetch(:objects, [])
5✔
375
    else
376
      # Return the list based of files honoring the work status
377
      post_curation_s3_resources
74✔
378
    end
379
  end
380

381
  def s3_files
1✔
382
    pre_curation_uploads
×
383
  end
384

385
  def s3_client
1✔
386
    s3_query_service.client
22✔
387
  end
388

389
  delegate :bucket_name, :prefix, to: :s3_query_service
1✔
390
  delegate :doi_attribute_url, :curator_or_current_uid, to: :datacite_service
1✔
391

392
  # Generates the S3 Object key
393
  # @return [String]
394
  def s3_object_key
1✔
395
    "#{doi}/#{id}"
60✔
396
  end
397

398
  # Transmit a HEAD request for the S3 Bucket directory for this Work
399
  # @param bucket_name location to be checked to be found
400
  # @return [Aws::S3::Types::HeadObjectOutput]
401
  def find_post_curation_s3_dir(bucket_name:)
1✔
402
    # TODO: Directories really do not exists in S3
403
    #      if we really need this check then we need to do something else to check the bucket
404
    s3_client.head_object({
22✔
405
                            bucket: bucket_name,
406
                            key: s3_object_key
407
                          })
408
    true
1✔
409
  rescue Aws::S3::Errors::NotFound
410
    nil
21✔
411
  end
412

413
  # Generates the JSON serialized expression of the Work
414
  # @param args [Array<Hash>]
415
  # @option args [Boolean] :force_post_curation Force the request of AWS S3
416
  #   Resources, clearing the in-memory cache
417
  # @return [String]
418
  def as_json(*args)
1✔
419
    files = files_as_json(*args)
36✔
420

421
    # to_json returns a string of serialized JSON.
422
    # as_json returns the corresponding hash.
423
    {
424
      "resource" => resource.as_json,
36✔
425
      "files" => files,
426
      "group" => group.as_json.except("id"),
427
      "embargo_date" => embargo_date_as_json,
428
      "created_at" => format_date_for_solr(created_at),
429
      "updated_at" => format_date_for_solr(updated_at)
430
    }
431
  end
432

433
  # Format the date for Apache Solr
434
  # @param date [ActiveSupport::TimeWithZone]
435
  # @return [String]
436
  def format_date_for_solr(date)
1✔
437
    date.strftime("%Y-%m-%dT%H:%M:%SZ")
72✔
438
  end
439

440
  def pre_curation_uploads_count
1✔
441
    s3_query_service.file_count
2✔
442
  end
443

444
  delegate :ark, :doi, :resource_type, :resource_type=, :resource_type_general, :resource_type_general=,
1✔
445
           :to_xml, to: :resource
446

447
  # S3QueryService object associated with this Work
448
  # @return [S3QueryService]
449
  def s3_query_service
1✔
450
    mode = approved? ? "postcuration" : "precuration"
1,338✔
451
    @s3_query_service ||= S3QueryService.new(self, mode)
1,338✔
452
  end
453

454
  def past_snapshots
1✔
455
    UploadSnapshot.where(work: self)
×
456
  end
457

458
  # Build or find persisted UploadSnapshot models for this Work
459
  # @param [integer] user_id optional user to assign the snapshot to
460
  # @return [UploadSnapshot]
461
  def reload_snapshots(user_id: nil)
1✔
462
    work_changes = []
20✔
463
    s3_files = pre_curation_uploads
20✔
464
    s3_filenames = s3_files.map(&:filename)
20✔
465

466
    upload_snapshot = latest_snapshot
20✔
467

468
    upload_snapshot.snapshot_deletions(work_changes, s3_filenames)
20✔
469

470
    upload_snapshot.snapshot_modifications(work_changes, s3_files)
20✔
471

472
    # Create WorkActivity models with the set of changes
473
    unless work_changes.empty?
20✔
474
      new_snapshot = UploadSnapshot.new(work: self, url: s3_query_service.prefix)
14✔
475
      new_snapshot.store_files(s3_files)
14✔
476
      new_snapshot.save!
14✔
477
      WorkActivity.add_work_activity(id, work_changes.to_json, user_id, activity_type: WorkActivity::FILE_CHANGES)
14✔
478
    end
479
  end
480

481
  def self.presenter_class
1✔
482
    WorkPresenter
136✔
483
  end
484

485
  def presenter
1✔
486
    self.class.presenter_class.new(work: self)
136✔
487
  end
488

489
  def changes
1✔
490
    @changes ||= []
148✔
491
  end
492

493
  def track_change(action, filename)
1✔
494
    changes << { action:, filename: }
33✔
495
  end
496

497
  # rubocop:disable Naming/PredicateName
498
  def has_rights?(rights_id)
1✔
499
    resource.rights_many.index { |rights| rights.identifier == rights_id } != nil
1,855✔
500
  end
501
  # rubocop:enable Naming/PredicateName
502

503
  # This is the solr id / work show page in PDC Discovery
504
  def pdc_discovery_url
1✔
505
    "https://datacommons.princeton.edu/discovery/catalog/doi-#{doi.tr('/', '-').tr('.', '-')}"
205✔
506
  end
507

508
  # Determine whether or not the Work is under active embargo
509
  # @return [Boolean]
510
  def embargoed?
1✔
511
    return false if embargo_date.blank?
260✔
512

513
    current_date = Time.zone.now
7✔
514
    embargo_date >= current_date
7✔
515
  end
516

517
  def upload_count
1✔
518
    @upload_count ||= s3_query_service.count_objects
940✔
519
  end
520

521
  protected
1✔
522

523
    def work_validator
1✔
524
      @work_validator ||= WorkValidator.new(self)
1,399✔
525
    end
526

527
    # This must be protected, NOT private for ActiveRecord to work properly with this attribute.
528
    #   Protected will still keep others from setting the metatdata, but allows ActiveRecord the access it needs
529
    def metadata=(metadata)
1✔
530
      super
2,132✔
531
      @resource = PDCMetadata::Resource.new_from_jsonb(metadata)
2,132✔
532
    end
533

534
  private
1✔
535

536
    def publish(user)
1✔
537
      datacite_service.publish_doi(user)
28✔
538
      update_ark_information
28✔
539
      publish_precurated_files(user)
28✔
540
      save!
27✔
541
    end
542

543
    # Update EZID (our provider of ARKs) with the new information for this work.
544
    def update_ark_information
1✔
545
      # We only want to update the ark url under certain conditions.
546
      # Set this value in config/update_ark_url.yml
547
      if Rails.configuration.update_ark_url
28✔
548
        if ark.present?
8✔
549
          Ark.update(ark, datacite_service.doi_attribute_url)
3✔
550
        end
551
      end
552
    end
553

554
    def track_state_change(user, state = aasm.to_state)
1✔
555
      uw = UserWork.new(user_id: user.id, work_id: id, state:)
164✔
556
      uw.save!
164✔
557
      WorkActivity.add_work_activity(id, "marked as #{state.to_s.titleize}", user.id, activity_type: WorkActivity::SYSTEM)
164✔
558
      WorkStateTransitionNotification.new(self, user.id).send
164✔
559
    end
560

561
    # Request S3 Bucket Objects associated with this Work
562
    # @return [Array<S3File>]
563
    def s3_resources
1✔
564
      data_profile = s3_query_service.data_profile
46✔
565
      data_profile.fetch(:objects, [])
46✔
566
    end
567
    alias pre_curation_s3_resources s3_resources
1✔
568

569
    def s3_object_persisted?(s3_file)
1✔
570
      uploads_keys = uploads.map(&:key)
×
571
      uploads_keys.include?(s3_file.key)
×
572
    end
573

574
    def publish_precurated_files(user)
1✔
575
      # We need to explicitly check the to post-curation bucket here.
576
      s3_post_curation_query_service = S3QueryService.new(self, "postcuration")
22✔
577

578
      s3_dir = find_post_curation_s3_dir(bucket_name: s3_post_curation_query_service.bucket_name)
22✔
579
      raise(StandardError, "Attempting to publish a Work with an existing S3 Bucket directory for: #{s3_object_key}") unless s3_dir.nil?
22✔
580

581
      # Copy the pre-curation S3 Objects to the post-curation S3 Bucket...
582
      s3_query_service.publish_files(user)
21✔
583
    end
584

585
    def latest_snapshot
1✔
586
      return upload_snapshots.first unless upload_snapshots.empty?
20✔
587

588
      UploadSnapshot.new(work: self, files: [])
13✔
589
    end
590

591
    def datacite_service
1✔
592
      @datacite_service ||= PULDatacite.new(self)
59✔
593
    end
594

595
    def files_as_json(*args)
1✔
596
      return [] if embargoed?
36✔
597

598
      force_post_curation = args.any? { |arg| arg[:force_post_curation] == true }
65✔
599

600
      # Pre-curation files are not accessible externally,
601
      # so we are not interested in listing them in JSON.
602
      post_curation_uploads(force_post_curation:).map do |upload|
33✔
603
        {
604
          "filename": upload.filename,
10✔
605
          "size": upload.size,
606
          "display_size": upload.display_size,
607
          "url": upload.globus_url
608
        }
609
      end
610
    end
611

612
    def embargo_date_as_json
1✔
613
      if embargo_date.present?
36✔
614
        embargo_datetime = embargo_date.to_datetime
5✔
615
        embargo_date_iso8601 = embargo_datetime.iso8601
5✔
616
        # Apache Solr timestamps require the following format:
617
        # 1972-05-20T17:33:18Z
618
        # https://solr.apache.org/guide/solr/latest/indexing-guide/date-formatting-math.html
619
        embargo_date_iso8601.gsub(/\+.+$/, "Z")
5✔
620
      end
621
    end
622
end
623
# rubocop:enable Metrics/ClassLength
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc