| defmodule Plausible.Imported.CSVImporterTest do |
| use Plausible.Repo |
| use PlausibleWeb.ConnCase |
| use Bamboo.Test |
| alias Plausible.Imported.{CSVImporter, SiteImport} |
| require SiteImport |
|
|
| doctest CSVImporter, import: true |
|
|
| on_ee do |
| @moduletag :minio |
| end |
|
|
| describe "new_import/3 and parse_args/1" do |
| setup [:create_user, :create_site] |
|
|
| test "parses job args properly", %{user: user, site: site} do |
| tables = [ |
| "imported_browsers", |
| "imported_custom_events", |
| "imported_devices", |
| "imported_entry_pages", |
| "imported_exit_pages", |
| "imported_locations", |
| "imported_operating_systems", |
| "imported_pages", |
| "imported_sources", |
| "imported_visitors" |
| ] |
|
|
| start_date = "20231001" |
| end_date = "20240102" |
|
|
| uploads = |
| Enum.map(tables, fn table -> |
| filename = "#{table}_#{start_date}_#{end_date}.csv" |
|
|
| on_ee do |
| %{ |
| "filename" => filename, |
| "s3_url" => |
| "https://bucket-name.s3.eu-north-1.amazonaws.com/#{site.id}/#{filename}-some-random-suffix" |
| } |
| else |
| %{ |
| "filename" => filename, |
| "local_path" => "/tmp/some-random-path" |
| } |
| end |
| end) |
|
|
| date_range = CSVImporter.date_range(uploads) |
|
|
| assert {:ok, job} = |
| CSVImporter.new_import(site, user, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ) |
|
|
| assert %Oban.Job{args: %{"import_id" => import_id, "uploads" => ^uploads} = args} = |
| Repo.reload!(job) |
|
|
| assert [ |
| %{ |
| id: ^import_id, |
| source: :csv, |
| start_date: ~D[2023-10-01], |
| end_date: ~D[2024-01-02], |
| status: SiteImport.pending() |
| } |
| ] = Plausible.Imported.list_all_imports(site) |
|
|
| assert CSVImporter.parse_args(args) == [ |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ] |
| end |
| end |
|
|
| describe "import_data/2" do |
| setup [:create_user, :create_site, :clean_buckets] |
|
|
| @describetag :tmp_dir |
|
|
| test "imports tables from S3", %{site: site, user: user} = ctx do |
| _ = ctx |
|
|
| csvs = [ |
| %{ |
| name: "imported_browsers_20211230_20211231.csv", |
| body: """ |
| "date","browser","visitors","visits","visit_duration","bounces" |
| "2021-12-30","Amazon Silk",2,2,0,2 |
| "2021-12-30","Chrome",31,32,329,29 |
| "2021-12-30","Edge",3,3,0,3 |
| "2021-12-30","Firefox",1,1,0,1 |
| "2021-12-30","Internet Explorer",1,1,0,1 |
| "2021-12-30","Mobile App",2,2,0,2 |
| "2021-12-30","Mobile App",4,4,0,4 |
| "2021-12-30","Mobile App",1,1,0,1 |
| "2021-12-30","Safari",32,36,0,36 |
| "2021-12-30","Samsung Internet",2,2,0,2 |
| "2021-12-30","UC Browser",1,1,0,1 |
| "2021-12-31","Chrome",24,25,75,23 |
| "2021-12-31","Edge",3,3,0,3 |
| "2021-12-31","Firefox",1,1,466,0 |
| "2021-12-31","Mobile App",4,5,0,5 |
| "2021-12-31","Mobile App",4,4,0,4 |
| "2021-12-31","Mobile App",1,1,85,0 |
| "2021-12-31","Safari",37,45,1957,42 |
| "2021-12-31","Samsung Internet",1,1,199,0 |
| """ |
| }, |
| %{ |
| name: "imported_devices_20211230_20220102.csv", |
| body: """ |
| "date","device","visitors","visits","visit_duration","bounces" |
| "2021-12-30","Desktop",25,28,75,27 |
| "2021-12-30","Mobile",49,51,254,49 |
| "2021-12-30","Tablet",6,6,0,6 |
| "2021-12-31","Desktop",20,26,496,24 |
| "2021-12-31","Mobile",50,54,1842,49 |
| "2021-12-31","Tablet",5,5,444,4 |
| "2022-01-01","Desktop",33,34,1117,32 |
| "2022-01-01","Mobile",55,60,306,54 |
| "2022-01-01","Tablet",8,8,419,7 |
| "2022-01-02","Desktop",28,28,86,26 |
| "2022-01-02","Mobile",66,73,2450,65 |
| "2022-01-02","Tablet",9,9,0,9 |
| """ |
| }, |
| %{ |
| name: "imported_entry_pages_20211230_20211231.csv", |
| body: """ |
| "date","visitors","entrances","visit_duration","bounces","entry_page" |
| "2021-12-30",6,6,0,6,"/14776416252794997127" |
| "2021-12-30",1,1,0,1,"/15455127321321119046" |
| "2021-12-30",1,1,43,0,"/10399835914295020763" |
| "2021-12-30",1,1,0,1,"/9102354072466236765" |
| "2021-12-30",1,1,0,1,"/1586391735863371077" |
| "2021-12-30",1,1,0,1,"/3457026921000639206" |
| "2021-12-30",2,3,0,3,"/6077502147861556415" |
| "2021-12-30",1,1,0,1,"/14280570555317344651" |
| "2021-12-30",3,3,0,3,"/5284268072698982201" |
| "2021-12-30",1,1,0,1,"/7478911940502018071" |
| "2021-12-30",1,1,0,1,"/6402607186523575652" |
| "2021-12-30",2,2,0,2,"/9962503789684934900" |
| "2021-12-30",8,10,0,10,"/13595620304963848161" |
| "2021-12-30",2,2,0,2,"/17019199732013993436" |
| "2021-12-30",31,31,211,30,"/9874837495456455794" |
| "2021-12-31",4,4,0,4,"/14776416252794997127" |
| "2021-12-31",1,1,0,1,"/8738789417178304429" |
| "2021-12-31",1,1,0,1,"/7445073500314667742" |
| "2021-12-31",1,1,0,1,"/4897404798407749335" |
| "2021-12-31",1,1,45,0,"/11263893625781431659" |
| "2021-12-31",1,1,0,1,"/16478773157730928089" |
| "2021-12-31",1,1,0,1,"/1710995203264225236" |
| "2021-12-31",1,1,0,1,"/14280570555317344651" |
| "2021-12-31",4,5,444,4,"/5284268072698982201" |
| "2021-12-31",2,2,466,1,"/7478911940502018071" |
| "2021-12-31",9,16,1455,15,"/13595620304963848161" |
| "2021-12-31",25,25,88,23,"/9874837495456455794" |
| """ |
| }, |
| %{ |
| name: "imported_exit_pages_20211230_20211231.csv", |
| body: """ |
| "date","visitors","exits","exit_page" |
| "2021-12-30",6,6,"/14776416252794997127" |
| "2021-12-30",1,1,"/15455127321321119046" |
| "2021-12-30",1,1,"/9102354072466236765" |
| "2021-12-30",1,1,"/4457889102355683190" |
| "2021-12-30",1,1,"/12105301321223776356" |
| "2021-12-30",1,2,"/1526239929864936398" |
| "2021-12-30",1,1,"/7478911940502018071" |
| "2021-12-30",1,1,"/6402607186523575652" |
| "2021-12-30",2,2,"/9962503789684934900" |
| "2021-12-30",8,10,"/13595620304963848161" |
| "2021-12-30",2,2,"/17019199732013993436" |
| "2021-12-30",32,32,"/9874837495456455794" |
| "2021-12-31",4,4,"/14776416252794997127" |
| "2021-12-31",1,1,"/8738789417178304429" |
| "2021-12-31",1,1,"/7445073500314667742" |
| "2021-12-31",1,1,"/4897404798407749335" |
| "2021-12-31",1,1,"/11263893625781431659" |
| "2021-12-31",1,1,"/16478773157730928089" |
| "2021-12-31",1,1,"/1710995203264225236" |
| """ |
| }, |
| %{ |
| name: "imported_custom_events_20211230_20211231.csv", |
| body: """ |
| "date","name","link_url","path","visitors","events" |
| "2021-12-30","Filter Menu: Open","","",300,1652 |
| "2021-12-30","Signup","","",40,82 |
| "2021-12-30","Signup via invitation","","",5,10 |
| "2021-12-31","Signup via invitation","","",5,10 |
| "2021-12-31","Signup","","",39,78 |
| "2021-12-31","Filter Menu: Open","","",295,1394 |
| "2021-12-31","Newsletter signup","","",1,2 |
| """ |
| }, |
| %{ |
| name: "imported_locations_20211230_20211231.csv", |
| body: """ |
| "date","country","region","city","visitors","visits","visit_duration","bounces" |
| "2021-12-30","AU","",0,1,1,43,0 |
| "2021-12-30","AU","",2078025,3,4,211,3 |
| "2021-12-30","AU","",2147714,2,2,0,2 |
| "2021-12-30","AU","",2158177,2,2,0,2 |
| "2021-12-30","AU","",2174003,1,1,0,1 |
| "2021-12-30","BE","",0,1,1,0,1 |
| "2021-12-30","BE","",2792196,1,1,0,1 |
| "2021-12-30","BR","",0,1,1,0,1 |
| "2021-12-30","CA","",0,1,1,0,1 |
| "2021-12-30","PL","",0,1,1,0,1 |
| "2021-12-30","PL","",756135,1,1,0,1 |
| "2021-12-30","US","",0,1,1,0,1 |
| "2021-12-30","US","",0,1,1,0,1 |
| "2021-12-30","US","",0,1,1,0,1 |
| "2021-12-30","US","",0,1,1,0,1 |
| "2021-12-30","US","",4063926,1,1,0,1 |
| "2021-12-30","US","",4074013,1,3,0,3 |
| "2021-12-30","US","",5089478,1,1,0,1 |
| "2021-12-31","AU","",2147714,3,3,0,3 |
| "2021-12-31","AU","",2158177,2,2,0,2 |
| "2021-12-31","CA","",0,1,1,0,1 |
| "2021-12-31","IT","",3176959,1,1,85,0 |
| "2021-12-31","KR","",1835848,1,1,0,1 |
| "2021-12-31","LV","",456172,1,1,0,1 |
| "2021-12-31","MX","",3530757,2,3,0,3 |
| "2021-12-31","NL","",0,1,1,0,1 |
| "2021-12-31","NL","",0,1,2,0,2 |
| "2021-12-31","NL","",2745321,1,1,0,1 |
| "2021-12-31","NO","",0,1,1,199,0 |
| "2021-12-31","SE","",0,1,1,0,1 |
| "2021-12-31","SG","",1880252,1,1,0,1 |
| """ |
| }, |
| %{ |
| name: "imported_operating_systems_20211230_20220101.csv", |
| body: """ |
| "date","operating_system","visitors","visits","visit_duration","bounces" |
| "2021-12-30","Android",25,26,254,24 |
| "2021-12-30","Mac",13,16,0,16 |
| "2021-12-30","Windows",12,12,75,11 |
| "2021-12-30","iOS",30,31,0,31 |
| "2021-12-31","Android",15,16,329,13 |
| "2021-12-31","Mac",13,19,0,19 |
| "2021-12-31","Windows",7,7,496,5 |
| "2021-12-31","iOS",40,43,1957,40 |
| "2022-01-01","",17,18,0,18 |
| "2022-01-01","Android",25,28,32,26 |
| "2022-01-01","Chrome OS",1,1,0,1 |
| "2022-01-01","Mac",6,6,0,6 |
| "2022-01-01","Windows",9,9,1117,7 |
| "2022-01-01","iOS",38,40,693,35 |
| """ |
| }, |
| %{ |
| name: "imported_pages_20211230_20220101.csv", |
| body: """ |
| "date","visitors","pageviews","hostname","page" |
| "2021-12-30",1,1,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-30",1,1,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-30",6,6,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-30",1,1,"lucky.numbers.com","/9102354072466236765" |
| "2021-12-30",1,1,"lucky.numbers.com","/7478911940502018071" |
| "2021-12-30",1,1,"lucky.numbers.com","/6402607186523575652" |
| "2021-12-30",2,2,"lucky.numbers.com","/9962503789684934900" |
| "2021-12-30",8,10,"lucky.numbers.com","/13595620304963848161" |
| "2021-12-30",2,2,"lucky.numbers.com","/17019199732013993436" |
| "2021-12-30",32,33,"lucky.numbers.com","/9874837495456455794" |
| "2021-12-31",4,4,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-31",1,1,"lucky.numbers.com","/8738789417178304429" |
| "2021-12-31",1,1,"lucky.numbers.com","/7445073500314667742" |
| "2021-12-31",1,1,"lucky.numbers.com","/4897404798407749335" |
| "2021-12-31",1,2,"lucky.numbers.com","/11263893625781431659" |
| "2022-01-01",2,2,"lucky.numbers.com","/5878724061840196349" |
| """ |
| }, |
| %{ |
| name: "imported_sources_20211230_20220106.csv", |
| body: """ |
| "date","source","utm_medium","utm_campaign","utm_content","utm_term","visitors","visits","visit_duration","bounces" |
| "2021-12-30","","","","","",25,26,254,24 |
| "2021-12-30","Hacker News","referral","","","",2,2,0,2 |
| "2021-12-30","Google","organic","","","",20,22,75,21 |
| "2021-12-30","Pinterest","referral","","","",25,26,0,26 |
| "2021-12-30","baidu","organic","","","",1,1,0,1 |
| "2021-12-30","yahoo","organic","","","",3,3,0,3 |
| "2021-12-31","","","","","",16,16,199,15 |
| "2021-12-31","Bing","organic","","","",1,1,0,1 |
| "2021-12-31","DuckDuckGo","organic","","","",1,1,0,1 |
| "2021-12-31","Hacker News","referral","","","",1,1,466,0 |
| "2021-12-31","Google","organic","","","",25,32,85,31 |
| "2021-12-31","Pinterest","referral","","","",22,24,88,22 |
| "2021-12-31","yahoo","organic","","","",3,3,1899,1 |
| "2022-01-01","","","","","",37,38,1137,35 |
| "2022-01-01","Bing","organic","","","",2,2,171,1 |
| "2022-01-01","DuckDuckGo","organic","","","",2,3,0,3 |
| "2022-01-01","Hacker News","referral","","","",1,1,0,1 |
| "2022-01-01","Google","referral","","","",1,1,0,1 |
| "2022-01-01","Google","organic","","","",21,23,115,19 |
| "2022-01-01","Pinterest","referral","","","",29,30,0,30 |
| "2022-01-01","yahoo","organic","","","",3,3,419,2 |
| "2022-01-06","","","","","",37,38,430,36 |
| "2022-01-06","Bing","organic","","","how lucky am I as UInt64",1,1,0,1 |
| "2022-01-06","Bing","organic","","","",3,3,10,2 |
| """ |
| }, |
| %{ |
| name: "imported_visitors_20111225_20111230.csv", |
| body: """ |
| "date","visitors","pageviews","bounces","visits","visit_duration" |
| "2011-12-25",5,50,2,7,8640 |
| "2011-12-26",3,4,2,3,43 |
| "2011-12-27",3,6,2,4,2313 |
| "2011-12-28",6,30,4,8,2264 |
| "2011-12-29",4,8,5,6,136 |
| "2011-12-30",1,1,1,1,0 |
| """ |
| } |
| ] |
|
|
| uploads = |
| for %{name: name, body: body} <- csvs do |
| on_ee do |
| %{s3_url: s3_url} = Plausible.S3.import_presign_upload(site.id, name) |
| [bucket, key] = String.split(URI.parse(s3_url).path, "/", parts: 2) |
| ExAws.request!(ExAws.S3.put_object(bucket, key, body)) |
| %{"filename" => name, "s3_url" => s3_url} |
| else |
| local_path = Path.join(ctx.tmp_dir, name) |
| File.write!(local_path, body) |
| %{"filename" => name, "local_path" => local_path} |
| end |
| end |
|
|
| date_range = CSVImporter.date_range(uploads) |
|
|
| {:ok, _job} = |
| CSVImporter.new_import(site, user, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ) |
|
|
| assert %{success: 1} = Oban.drain_queue(queue: :analytics_imports, with_safety: false) |
|
|
| assert %SiteImport{ |
| start_date: ~D[2011-12-25], |
| end_date: ~D[2022-01-06], |
| source: :csv, |
| status: :completed |
| } = Repo.get_by!(SiteImport, site_id: site.id) |
|
|
| assert Plausible.Stats.Clickhouse.imported_pageview_count(site) == 99 |
| end |
|
|
| test "imports scroll_depth as 0 when the column does not exist in pages CSV", |
| %{site: site, user: user} = ctx do |
| _ = ctx |
|
|
| pages_csv = |
| %{ |
| name: "imported_pages_20211230_20220101.csv", |
| body: """ |
| "date","visitors","pageviews","hostname","page" |
| "2021-12-30",1,1,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-30",1,1,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-30",6,6,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-30",1,1,"lucky.numbers.com","/9102354072466236765" |
| "2021-12-30",1,1,"lucky.numbers.com","/7478911940502018071" |
| "2021-12-30",1,1,"lucky.numbers.com","/6402607186523575652" |
| "2021-12-30",2,2,"lucky.numbers.com","/9962503789684934900" |
| "2021-12-30",8,10,"lucky.numbers.com","/13595620304963848161" |
| "2021-12-30",2,2,"lucky.numbers.com","/17019199732013993436" |
| "2021-12-30",32,33,"lucky.numbers.com","/9874837495456455794" |
| "2021-12-31",4,4,"lucky.numbers.com","/14776416252794997127" |
| "2021-12-31",1,1,"lucky.numbers.com","/8738789417178304429" |
| "2021-12-31",1,1,"lucky.numbers.com","/7445073500314667742" |
| "2021-12-31",1,1,"lucky.numbers.com","/4897404798407749335" |
| "2021-12-31",1,2,"lucky.numbers.com","/11263893625781431659" |
| "2022-01-01",2,2,"lucky.numbers.com","/5878724061840196349" |
| """ |
| } |
|
|
| uploads = |
| on_ee do |
| %{s3_url: s3_url} = Plausible.S3.import_presign_upload(site.id, pages_csv.name) |
| [bucket, key] = String.split(URI.parse(s3_url).path, "/", parts: 2) |
| ExAws.request!(ExAws.S3.put_object(bucket, key, pages_csv.body)) |
| %{"filename" => pages_csv.name, "s3_url" => s3_url} |
| else |
| local_path = Path.join(ctx.tmp_dir, pages_csv.name) |
| File.write!(local_path, pages_csv.body) |
| %{"filename" => pages_csv.name, "local_path" => local_path} |
| end |
| |> List.wrap() |
|
|
| date_range = CSVImporter.date_range(uploads) |
|
|
| {:ok, _job} = |
| CSVImporter.new_import(site, user, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ) |
|
|
| assert %{success: 1} = Oban.drain_queue(queue: :analytics_imports, with_safety: false) |
|
|
| assert %SiteImport{ |
| start_date: ~D[2021-12-30], |
| end_date: ~D[2022-01-01], |
| source: :csv, |
| status: :completed |
| } = Repo.get_by!(SiteImport, site_id: site.id) |
|
|
| q = |
| from( |
| i in "imported_pages", |
| where: i.site_id == ^site.id, |
| select: %{ |
| total_scroll_depth: i.total_scroll_depth, |
| total_scroll_depth_visits: i.total_scroll_depth_visits |
| } |
| ) |
|
|
| assert List.duplicate( |
| %{ |
| total_scroll_depth: 0, |
| total_scroll_depth_visits: 0 |
| }, |
| 16 |
| ) == Plausible.IngestRepo.all(q) |
| end |
|
|
| test "accepts cells without quotes", %{site: site, user: user} = ctx do |
| _ = ctx |
|
|
| csvs = [ |
| %{ |
| name: "imported_visitors_20111225_20111230.csv", |
| body: """ |
| date,visitors,pageviews,bounces,visits,visit_duration |
| 2011-12-25,5,50,2,7,8640 |
| 2011-12-26,3,4,2,3,43 |
| 2011-12-27,3,6,2,4,2313 |
| 2011-12-28,6,30,4,8,2264 |
| 2011-12-29,4,8,5,6,136 |
| 2011-12-30,1,1,1,1,0 |
| """ |
| } |
| ] |
|
|
| uploads = |
| for %{name: name, body: body} <- csvs do |
| on_ee do |
| %{s3_url: s3_url} = Plausible.S3.import_presign_upload(site.id, name) |
| [bucket, key] = String.split(URI.parse(s3_url).path, "/", parts: 2) |
| ExAws.request!(ExAws.S3.put_object(bucket, key, body)) |
| %{"filename" => name, "s3_url" => s3_url} |
| else |
| local_path = Path.join(ctx.tmp_dir, name) |
| File.write!(local_path, body) |
| %{"filename" => name, "local_path" => local_path} |
| end |
| end |
|
|
| date_range = CSVImporter.date_range(uploads) |
|
|
| {:ok, _job} = |
| CSVImporter.new_import(site, user, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ) |
|
|
| assert %{success: 1} = Oban.drain_queue(queue: :analytics_imports, with_safety: false) |
|
|
| assert %SiteImport{ |
| start_date: ~D[2011-12-25], |
| end_date: ~D[2011-12-30], |
| source: :csv, |
| status: :completed |
| } = Repo.get_by!(SiteImport, site_id: site.id) |
|
|
| assert Plausible.Stats.Clickhouse.imported_pageview_count(site) == 99 |
| end |
|
|
| test "fails on invalid CSV", %{site: site, user: user} = ctx do |
| _ = ctx |
|
|
| csvs = [ |
| %{ |
| name: "imported_browsers_20211230_20211231.csv", |
| body: """ |
| "date","browser","visitors","visits","visit_duration","bounces" |
| "2021-12-30","Amazon Silk",2,2,0,2 |
| "2021-12-30","Chrome",31,32,329,29 |
| "2021-12-30","Edge",3,3,0,3 |
| "2021-12-30","Firefox",1,1,0,1 |
| "2021-12-30","Internet Explorer",1,1,0,1 |
| "2021-12-30","Mobile App",2,2,0,2 |
| "2021-12-31","Mobile App",4,4,0,4 |
| """ |
| }, |
| %{ |
| name: "imported_devices_20211230_20211231.csv", |
| body: """ |
| "date","device","visitors","visit_duration","bounces" |
| "2021-12-30","Desktop",28,ehhhh.... |
| """ |
| } |
| ] |
|
|
| uploads = |
| for %{name: name, body: body} <- csvs do |
| on_ee do |
| %{s3_url: s3_url} = Plausible.S3.import_presign_upload(site.id, name) |
| [bucket, key] = String.split(URI.parse(s3_url).path, "/", parts: 2) |
| ExAws.request!(ExAws.S3.put_object(bucket, key, body)) |
| %{"filename" => name, "s3_url" => s3_url} |
| else |
| local_path = Path.join(ctx.tmp_dir, name) |
| File.write!(local_path, body) |
| %{"filename" => name, "local_path" => local_path} |
| end |
| end |
|
|
| date_range = CSVImporter.date_range(uploads) |
|
|
| {:ok, _job} = |
| CSVImporter.new_import(site, user, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ) |
|
|
| assert %{discard: 1} = Oban.drain_queue(queue: :analytics_imports, with_safety: false) |
|
|
| |
| |
| |
|
|
| assert %SiteImport{id: import_id, source: :csv, status: :failed} = |
| Repo.get_by!(SiteImport, site_id: site.id) |
|
|
| |
| imported_browsers_q = from b in "imported_browsers", where: b.import_id == ^import_id |
| assert await_clickhouse_count(imported_browsers_q, 0) |
| end |
| end |
|
|
| describe "export -> import" do |
| setup [:create_user, :log_in, :create_api_key, :use_api_key, :clean_buckets] |
|
|
| @tag :tmp_dir |
| test "it works", %{conn: conn, user: user, tmp_dir: tmp_dir} do |
| exported_site = new_site(owner: user) |
| imported_site = new_site(owner: user) |
|
|
| insert(:goal, site: exported_site, event_name: "Outbound Link: Click") |
| insert(:goal, site: exported_site, event_name: "404") |
| insert(:goal, site: exported_site, event_name: "click-button") |
| insert(:goal, site: imported_site, event_name: "Outbound Link: Click") |
| insert(:goal, site: imported_site, event_name: "404") |
| insert(:goal, site: imported_site, event_name: "click-button") |
|
|
| process_csv = fn path -> |
| [header | rows] = NimbleCSV.RFC4180.parse_string(File.read!(path), skip_headers: false) |
|
|
| site_id_column_index = |
| Enum.find_index(header, &(&1 == "site_id")) || |
| raise "couldn't find site_id column in CSV header #{inspect(header)}" |
|
|
| rows = |
| Enum.map(rows, fn row -> |
| List.replace_at(row, site_id_column_index, exported_site.id) |
| end) |
|
|
| NimbleCSV.RFC4180.dump_to_iodata([header | rows]) |
| end |
|
|
| Plausible.IngestRepo.query!([ |
| "insert into events_v2 format CSVWithNames\n", |
| process_csv.("fixture/markosaric_com_events_v2_2024_04_01_2024_04_30_dump.csv") |
| ]) |
|
|
| Plausible.IngestRepo.query!([ |
| "insert into sessions_v2 format CSVWithNames\n", |
| process_csv.("fixture/markosaric_com_sessions_v2_2024_04_01_2024_04_30_dump.csv") |
| ]) |
|
|
| initial_context = %{ |
| user: user, |
| tmp_dir: tmp_dir, |
| exported_site: exported_site, |
| imported_site: imported_site |
| } |
|
|
| %{site_import: site_import, exported_files: exported_files} = |
| initial_context |
| |> export_archive() |
| |> assert_email_notification() |
| |> download_archive() |
| |> unzip_archive() |
| |> upload_csvs() |
| |> run_import() |
|
|
| assert custom_props_export = |
| exported_files |
| |> Enum.find(&String.contains?(&1, "imported_custom_props_")) |
| |> File.read!() |
| |> String.split("\n") |
|
|
| assert ~s|"date","property","value","visitors","events"| in custom_props_export |
| assert ~s|"2024-04-01","author","Marko Saric",43,57| in custom_props_export |
| assert ~s|"2024-04-01","category","Posts",43,56| in custom_props_export |
|
|
| assert %SiteImport{ |
| start_date: ~D[2024-04-01], |
| end_date: ~D[2024-04-30], |
| source: :csv, |
| status: :completed |
| } = site_import |
|
|
| assert Plausible.Stats.Clickhouse.imported_pageview_count(exported_site) == 0 |
| assert Plausible.Stats.Clickhouse.imported_pageview_count(imported_site) == 1745 |
|
|
| |
| results = fn path, params -> |
| get(conn, path, params) |
| |> json_response(200) |
| |> Map.fetch!("results") |
| end |
|
|
| timeseries = fn params -> |
| results.("/api/v1/stats/timeseries", params) |
| end |
|
|
| common_params = fn site -> |
| %{ |
| "site_id" => site.domain, |
| "period" => "custom", |
| "date" => "2024-04-01,2024-04-30", |
| "with_imported" => true |
| } |
| end |
|
|
| breakdown = fn params_or_site, by -> |
| by_without_prefix = by |> Plausible.Stats.Filters.without_prefix() |> to_string() |
|
|
| params = |
| case params_or_site do |
| %Plausible.Site{} = site -> |
| common_params.(site) |
| |> Map.put("metrics", "visitors,visits,visit_duration,bounce_rate") |
| |> Map.put("limit", 1000) |
| |> Map.put("property", by) |
|
|
| params -> |
| params |
| end |
|
|
| Enum.sort_by( |
| results.("/api/v1/stats/breakdown", params), |
| &Map.fetch!(&1, by_without_prefix) |
| ) |
| end |
|
|
| goal_breakdown = fn site, by, filters -> |
| by_without_prefix = by |> Plausible.Stats.Filters.without_prefix() |> to_string() |
|
|
| params = |
| common_params.(site) |
| |> Map.put("metrics", "visitors,events,conversion_rate") |
| |> Map.put("filters", filters) |
| |> Map.put("limit", 1000) |
| |> Map.put("property", by) |
|
|
| Enum.sort_by( |
| results.("/api/v1/stats/breakdown", params), |
| &Map.fetch!(&1, by_without_prefix) |
| ) |
| end |
|
|
| |
| timeseries_params = fn site -> |
| Map.put( |
| common_params.(site), |
| "metrics", |
| "visitors,visits,pageviews,views_per_visit,visit_duration,bounce_rate" |
| ) |
| end |
|
|
| exported_timeseries = timeseries.(timeseries_params.(exported_site)) |
| imported_timeseries = timeseries.(timeseries_params.(imported_site)) |
|
|
| pairwise(exported_timeseries, imported_timeseries, fn exported, imported -> |
| assert exported["date"] == imported["date"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert_in_delta exported["visitors"], imported["visitors"], 1 |
| assert_in_delta exported["visits"], imported["visits"], 1 |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_timeseries, "views_per_visit")) == |
| [0.98, 1.2025, 1.29, 1.3699999999999999, 1.64] |
|
|
| assert summary(field(imported_timeseries, "views_per_visit")) == |
| [0.98, 1.1925, 1.29, 1.375, 1.64] |
|
|
| assert summary( |
| pairwise(exported_timeseries, imported_timeseries, fn exported, imported -> |
| abs(1 - imported["views_per_visit"] / exported["views_per_visit"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.0, 0.0325203252032521] |
|
|
| |
| pages_params = fn site -> |
| common_params.(site) |
| |> Map.put("metrics", "visitors,visits,pageviews,time_on_page,visit_duration,bounce_rate") |
| |> Map.put("limit", 1000) |
| |> Map.put("property", "event:page") |
| end |
|
|
| exported_pages = breakdown.(pages_params.(exported_site), "visit:page") |
| imported_pages = breakdown.(pages_params.(imported_site), "visit:page") |
|
|
| pairwise(exported_pages, imported_pages, fn exported, imported -> |
| assert exported["page"] == imported["page"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert exported["visit_duration"] == imported["visit_duration"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| end) |
|
|
| |
| assert summary(field(exported_pages, "visitors")) == [1, 1.0, 5.5, 17.75, 495] |
| assert summary(field(imported_pages, "visitors")) == [1, 1.75, 5.5, 20.0, 511] |
|
|
| assert summary( |
| pairwise(exported_pages, imported_pages, fn exported, imported -> |
| e = exported["visitors"] |
| i = imported["visitors"] |
|
|
| |
| if e > 5, do: abs(1 - e / i), else: 0 |
| end) |
| ) == [0, 0.0, 0.0, 0.0, 0.2857142857142857] |
|
|
| |
| assert summary(field(exported_pages, "visits")) == [1, 1.75, 5.5, 20.25, 526] |
| assert summary(field(imported_pages, "visits")) == [1, 1.75, 5.5, 20.25, 527] |
|
|
| assert summary( |
| pairwise(exported_pages, imported_pages, fn exported, imported -> |
| e = exported["visits"] |
| i = imported["visits"] |
|
|
| |
| if e > 4, do: abs(1 - e / i), else: 0 |
| end) |
| ) == [0, 0.0, 0.0, 0.0, 0.06666666666666665] |
|
|
| |
| exported_sources = breakdown.(exported_site, "visit:source") |
| imported_sources = breakdown.(imported_site, "visit:source") |
|
|
| pairwise(exported_sources, imported_sources, fn exported, imported -> |
| assert exported["source"] == imported["source"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert exported["visits"] == imported["visits"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_sources, "visitors")) == [1, 1.0, 1.0, 7.25, 582] |
| assert summary(field(imported_sources, "visitors")) == [1, 1.0, 1.0, 7.25, 587] |
|
|
| assert summary( |
| pairwise(exported_sources, imported_sources, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.0, 0.11111111111111116] |
|
|
| |
| assert breakdown.(exported_site, "visit:utm_medium") == |
| breakdown.(imported_site, "visit:utm_medium") |
|
|
| |
| exported_entry_pages = breakdown.(exported_site, "visit:entry_page") |
| imported_entry_pages = breakdown.(imported_site, "visit:entry_page") |
|
|
| pairwise(exported_entry_pages, imported_entry_pages, fn exported, imported -> |
| assert exported["entry_page"] == imported["entry_page"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert exported["visits"] == imported["visits"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_entry_pages, "visitors")) == [1, 1.0, 5.0, 12.5, 458] |
| assert summary(field(imported_entry_pages, "visitors")) == [1, 1.0, 5.0, 12.5, 473] |
|
|
| assert summary( |
| pairwise(exported_entry_pages, imported_entry_pages, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0, 0, 0, 0, 0.5] |
|
|
| |
| exported_cities = breakdown.(exported_site, "visit:city") |
| imported_cities = breakdown.(imported_site, "visit:city") |
|
|
| pairwise(exported_cities, imported_cities, fn exported, imported -> |
| assert exported["city"] == imported["city"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visits"], imported["visits"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_cities, "bounce_rate")) == [0, 87.0, 100.0, 100.0, 100] |
| assert summary(field(imported_cities, "bounce_rate")) == [0.0, 87.0, 100.0, 100.0, 100.0] |
|
|
| assert summary( |
| pairwise(exported_cities, imported_cities, fn exported, imported -> |
| e = exported["bounce_rate"] |
| i = imported["bounce_rate"] |
|
|
| if is_number(e) and is_number(i) and i > 0 do |
| abs(1 - e / i) |
| else |
| |
| assert e == i |
| _no_diff = 0 |
| end |
| end) |
| ) == [0.0, 0.0, 0.0, 0.0, 0.0] |
|
|
| |
| assert summary(field(exported_cities, "visit_duration")) == [0, 0.0, 0.0, 1.0, 1718] |
| assert summary(field(imported_cities, "visit_duration")) == [0.0, 0.0, 0.0, 1.0, 1718.0] |
|
|
| assert summary( |
| pairwise(exported_cities, imported_cities, fn exported, imported -> |
| e = exported["visit_duration"] |
| i = imported["visit_duration"] |
|
|
| if is_number(e) and is_number(i) and i > 0 do |
| abs(1 - e / i) |
| else |
| |
| assert e == i |
| _no_diff = 0 |
| end |
| end) |
| ) == [0, 0.0, 0.0, 0.0, 0.0] |
|
|
| |
| assert summary(field(exported_cities, "visitors")) == [1, 1.0, 1.0, 2.0, 22] |
| assert summary(field(imported_cities, "visitors")) == [1, 1.0, 1.0, 2.0, 30] |
|
|
| assert summary( |
| pairwise(exported_cities, imported_cities, fn exported, imported -> |
| e = exported["visitors"] |
| i = imported["visitors"] |
|
|
| |
| if e > 3, do: abs(1 - e / i), else: 0 |
| end) |
| ) == [0, 0.0, 0.0, 0.0, 0.2666666666666667] |
|
|
| |
| exported_devices = breakdown.(exported_site, "visit:device") |
| imported_devices = breakdown.(imported_site, "visit:device") |
|
|
| pairwise(exported_devices, imported_devices, fn exported, imported -> |
| assert exported["device"] == imported["device"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert exported["visits"] == imported["visits"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_devices, "visitors")) == [3, 21.75, 242.0, 535.25, 773] |
| assert summary(field(imported_devices, "visitors")) == [3, 21.75, 249.5, 551.0, 791] |
|
|
| assert summary( |
| pairwise(exported_devices, imported_devices, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0.0, 0.0, 0.011378002528445008, 0.025028787232157956, 0.031847133757961776] |
|
|
| |
| exported_browsers = breakdown.(exported_site, "visit:browser") |
| imported_browsers = breakdown.(imported_site, "visit:browser") |
|
|
| pairwise(exported_browsers, imported_browsers, fn exported, imported -> |
| assert exported["browser"] == imported["browser"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert exported["visits"] == imported["visits"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_browsers, "visitors")) == [1, 1.5, 8.0, 34.0, 572] |
| assert summary(field(imported_browsers, "visitors")) == [1, 1.5, 8.0, 34.0, 588] |
|
|
| assert summary( |
| pairwise(exported_browsers, imported_browsers, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.0, 0.037122969837587005] |
|
|
| |
| exported_os = breakdown.(exported_site, "visit:os") |
| imported_os = breakdown.(imported_site, "visit:os") |
|
|
| pairwise(exported_os, imported_os, fn exported, imported -> |
| assert exported["os"] == imported["os"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert exported["visits"] == imported["visits"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_os, "visitors")) == [3, 11.0, 56.0, 311.0, 390] |
| assert summary(field(imported_os, "visitors")) == [3, 11.0, 57.0, 315.0, 403] |
|
|
| assert summary( |
| pairwise(exported_os, imported_os, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.01754385964912286, 0.045592705167173286] |
|
|
| |
| exported_os_versions = breakdown.(exported_site, "visit:os_version") |
| imported_os_versions = breakdown.(imported_site, "visit:os_version") |
|
|
| pairwise(exported_os_versions, imported_os_versions, fn exported, imported -> |
| assert exported["os_version"] == imported["os_version"] |
| assert exported["bounce_rate"] == imported["bounce_rate"] |
| assert exported["visits"] == imported["visits"] |
| assert exported["pageviews"] == imported["pageviews"] |
| assert_in_delta exported["visit_duration"], imported["visit_duration"], 1 |
| end) |
|
|
| |
| assert summary(field(exported_os_versions, "visitors")) == [1, 1.0, 2.0, 7.0, 380] |
| assert summary(field(imported_os_versions, "visitors")) == [1, 1.0, 2.0, 7.0, 393] |
|
|
| assert summary( |
| pairwise(exported_os_versions, imported_os_versions, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.0, 0.33333333333333337] |
|
|
| |
| exported_goals = goal_breakdown.(exported_site, "event:goal", "") |
| imported_goals = goal_breakdown.(imported_site, "event:goal", "") |
|
|
| |
| assert summary(field(exported_goals, "visitors")) == [1, 6.5, 12.0, 41.0, 70] |
| assert summary(field(imported_goals, "visitors")) == [1, 6.5, 12.0, 50.0, 88] |
|
|
| assert summary( |
| pairwise(exported_goals, imported_goals, fn exported, imported -> |
| abs(1 - exported["visitors"] / imported["visitors"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.10227272727272729, 0.20454545454545459] |
|
|
| pairwise(exported_goals, imported_goals, fn exported, imported -> |
| assert exported["events"] == imported["events"] |
| end) |
|
|
| |
| assert summary(field(exported_goals, "conversion_rate")) == [ |
| 0.08, |
| 0.515, |
| 0.95, |
| 3.255, |
| 5.56 |
| ] |
|
|
| assert summary(field(imported_goals, "conversion_rate")) == [ |
| 0.08, |
| 0.505, |
| 0.93, |
| 3.87, |
| 6.81 |
| ] |
|
|
| assert summary( |
| pairwise(exported_goals, imported_goals, fn exported, imported -> |
| abs(1 - exported["conversion_rate"] / imported["conversion_rate"]) |
| end) |
| ) == [ |
| 0.0, |
| 0.010752688172043001, |
| 0.021505376344086002, |
| 0.10252948699729997, |
| 0.18355359765051393 |
| ] |
|
|
| |
| exported_url_props = |
| goal_breakdown.(exported_site, "event:props:url", "event:goal==Outbound Link: Click") |
|
|
| imported_url_props = |
| goal_breakdown.(imported_site, "event:props:url", "event:goal==Outbound Link: Click") |
|
|
| pairwise(exported_url_props, imported_url_props, fn exported, imported -> |
| assert exported["visitors"] == imported["visitors"] |
| assert exported["events"] == imported["events"] |
| end) |
|
|
| |
| assert summary(field(exported_url_props, "conversion_rate")) == [ |
| 0.08, |
| 0.08, |
| 0.08, |
| 0.08, |
| 0.79 |
| ] |
|
|
| assert summary(field(imported_url_props, "conversion_rate")) == [ |
| 0.08, |
| 0.08, |
| 0.08, |
| 0.08, |
| 0.77 |
| ] |
|
|
| assert summary( |
| pairwise(exported_url_props, imported_url_props, fn exported, imported -> |
| abs(1 - exported["conversion_rate"] / imported["conversion_rate"]) |
| end) |
| ) == [0.0, 0.0, 0.0, 0.0, 0.06666666666666665] |
|
|
| |
| exported_path_props = goal_breakdown.(exported_site, "event:props:path", "event:goal==404") |
| imported_path_props = goal_breakdown.(imported_site, "event:props:path", "event:goal==404") |
|
|
| pairwise(exported_path_props, imported_path_props, fn exported, imported -> |
| assert exported["visitors"] == imported["visitors"] |
| assert exported["events"] == imported["events"] |
| assert abs(exported["conversion_rate"] - imported["conversion_rate"]) < 0.011 |
| end) |
| end |
|
|
| @tag :tmp_dir |
| test "scroll_depth and time_on_page", %{conn: conn, user: user, tmp_dir: tmp_dir} do |
| exported_site = new_site(owner: user) |
| imported_site = new_site(owner: user) |
| FunWithFlags.enable(:new_time_on_page, for_actor: exported_site) |
|
|
| t0 = |
| NaiveDateTime.utc_now(:second) |
| |> NaiveDateTime.add(-5, :day) |
| |> NaiveDateTime.beginning_of_day() |
|
|
| [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) |
|
|
| stats = |
| [ |
| build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), |
| build(:engagement, |
| user_id: 12, |
| pathname: "/blog", |
| timestamp: t1, |
| scroll_depth: 20, |
| engagement_time: 50_000 |
| ), |
| build(:pageview, user_id: 12, pathname: "/another", timestamp: t1), |
| build(:engagement, |
| user_id: 12, |
| pathname: "/another", |
| timestamp: t2, |
| scroll_depth: 24, |
| engagement_time: 30_000 |
| ), |
| build(:pageview, user_id: 34, pathname: "/blog", timestamp: t0), |
| build(:engagement, |
| user_id: 34, |
| pathname: "/blog", |
| timestamp: t1, |
| scroll_depth: 17, |
| engagement_time: 10_000 |
| ), |
| build(:pageview, user_id: 34, pathname: "/another", timestamp: t1), |
| build(:engagement, |
| user_id: 34, |
| pathname: "/another", |
| timestamp: t2, |
| scroll_depth: 26, |
| engagement_time: 20_000 |
| ), |
| build(:pageview, user_id: 34, pathname: "/blog", timestamp: t2), |
| build(:engagement, |
| user_id: 34, |
| pathname: "/blog", |
| timestamp: t3, |
| scroll_depth: 60, |
| engagement_time: 30_000 |
| ), |
| build(:pageview, user_id: 56, pathname: "/blog", timestamp: t0), |
| build(:engagement, |
| user_id: 56, |
| pathname: "/blog", |
| timestamp: t1, |
| scroll_depth: 100, |
| engagement_time: 40_000 |
| ), |
| build(:pageview, user_id: 78, pathname: "/", timestamp: t0), |
| build(:engagement, |
| user_id: 78, |
| pathname: "/", |
| timestamp: t1, |
| scroll_depth: 20, |
| engagement_time: 10_000 |
| ), |
| build(:pageview, pathname: "/", timestamp: t1), |
| build(:pageview, pathname: "/blog", timestamp: NaiveDateTime.add(t0, 1, :day)) |
| ] |
| |> Enum.map(fn event -> Map.put(event, :hostname, "csv.test") end) |
|
|
| populate_stats(exported_site, stats) |
|
|
| initial_context = %{ |
| user: user, |
| tmp_dir: tmp_dir, |
| exported_site: exported_site, |
| imported_site: imported_site |
| } |
|
|
| %{site_import: site_import} = |
| initial_context |
| |> export_archive() |
| |> download_archive() |
| |> unzip_archive() |
| |> upload_csvs() |
| |> run_import() |
|
|
| assert %SiteImport{ |
| start_date: start_date, |
| end_date: end_date, |
| source: :csv, |
| has_scroll_depth: true, |
| status: :completed |
| } = site_import |
|
|
| expected_start_date = t0 |> NaiveDateTime.to_date() |
| expected_end_date = t0 |> NaiveDateTime.to_date() |> Date.add(1) |
|
|
| assert start_date == expected_start_date |
| assert end_date == expected_end_date |
| assert Plausible.Stats.Clickhouse.imported_pageview_count(exported_site) == 0 |
| assert Plausible.Stats.Clickhouse.imported_pageview_count(imported_site) == 9 |
|
|
| |
| imported_data = |
| from(i in "imported_pages", |
| where: i.site_id == ^imported_site.id, |
| select: %{ |
| date: i.date, |
| page: i.page, |
| total_scroll_depth: i.total_scroll_depth, |
| total_scroll_depth_visits: i.total_scroll_depth_visits, |
| total_time_on_page: i.total_time_on_page, |
| total_time_on_page_visits: i.total_time_on_page_visits |
| } |
| ) |
| |> Plausible.IngestRepo.all() |
|
|
| assert %{ |
| date: expected_start_date, |
| page: "/", |
| total_scroll_depth: 20, |
| total_scroll_depth_visits: 1, |
| total_time_on_page: 10, |
| total_time_on_page_visits: 1 |
| } in imported_data |
|
|
| assert %{ |
| date: expected_start_date, |
| page: "/another", |
| total_scroll_depth: 50, |
| total_scroll_depth_visits: 2, |
| total_time_on_page: 50, |
| total_time_on_page_visits: 2 |
| } in imported_data |
|
|
| assert %{ |
| date: expected_start_date, |
| page: "/blog", |
| total_scroll_depth: 180, |
| total_scroll_depth_visits: 3, |
| total_time_on_page: 130, |
| total_time_on_page_visits: 3 |
| } in imported_data |
|
|
| assert %{ |
| date: expected_end_date, |
| page: "/blog", |
| total_scroll_depth: 0, |
| total_scroll_depth_visits: 0, |
| total_time_on_page: 0, |
| total_time_on_page_visits: 0 |
| } in imported_data |
|
|
| |
| |
| expected_results = [ |
| %{"dimensions" => ["/"], "metrics" => [20]}, |
| %{"dimensions" => ["/another"], "metrics" => [25]}, |
| %{"dimensions" => ["/blog"], "metrics" => [60]} |
| ] |
|
|
| query_scroll_depth_per_page = fn conn, site -> |
| post(conn, "/api/v2/query", %{ |
| "site_id" => site.domain, |
| "metrics" => ["scroll_depth"], |
| "date_range" => "all", |
| "order_by" => [["scroll_depth", "asc"]], |
| "include" => %{"imports" => true}, |
| "dimensions" => ["event:page"] |
| }) |
| |> json_response(200) |
| |> Map.fetch!("results") |
| end |
|
|
| assert query_scroll_depth_per_page.(conn, exported_site) == expected_results |
| assert query_scroll_depth_per_page.(conn, imported_site) == expected_results |
| end |
| end |
|
|
| defp export_archive(%{user: user, exported_site: exported_site} = context) do |
| context = |
| on_ee do |
| assert {:ok, _job} = |
| Plausible.Exports.schedule_s3_export(exported_site.id, user.email) |
|
|
| context |
| else |
| assert {:ok, %{args: %{"local_path" => local_path}}} = |
| Plausible.Exports.schedule_local_export(exported_site.id, user.email) |
|
|
| Map.put(context, :local_path, local_path) |
| end |
|
|
| Oban.drain_queue(queue: :analytics_exports, with_safety: false) |
|
|
| context |
| end |
|
|
| defp assert_email_notification(%{user: user, exported_site: exported_site} = context) do |
| assert %{success: 1} = |
| Oban.drain_queue(queue: :notify_exported_analytics, with_safety: false) |
|
|
| assert_receive {:delivered_email, email}, _within = :timer.seconds(5) |
| assert email.to == [{user.name, user.email}] |
|
|
| assert email.html_body =~ |
| ~s[Please click <a href="http://localhost:8000/#{URI.encode_www_form(exported_site.domain)}/download/export?__team=#{exported_site.team.identifier}">here</a>] |
|
|
| assert email.text_body =~ |
| ~r[Please click here \(http://localhost:8000/#{URI.encode_www_form(exported_site.domain)}/download/export\?__team=#{exported_site.team.identifier}\) to start the download process.] |
|
|
| context |
| end |
|
|
| defp download_archive(%{tmp_dir: tmp_dir} = context) do |
| on_ee do |
| ExAws.request!( |
| ExAws.S3.download_file( |
| Plausible.S3.exports_bucket(), |
| to_string(context.exported_site.id), |
| Path.join(tmp_dir, "plausible-export.zip") |
| ) |
| ) |
| else |
| Plausible.File.mv!(context.local_path, Path.join(tmp_dir, "plausible-export.zip")) |
| end |
|
|
| context |
| end |
|
|
| defp unzip_archive(%{tmp_dir: tmp_dir} = context) do |
| {:ok, binary} = :file.read_file(to_charlist(Path.join(tmp_dir, "plausible-export.zip"))) |
|
|
| assert {:ok, files} = |
| :zip.unzip(binary, |
| cwd: to_charlist(tmp_dir) |
| ) |
|
|
| Map.put(context, :exported_files, Enum.map(files, &to_string/1)) |
| end |
|
|
| defp upload_csvs(%{exported_files: files} = context) do |
| uploads = |
| Enum.map(files, fn file -> |
| on_ee do |
| %{s3_url: s3_url} = Plausible.S3.import_presign_upload(context.imported_site.id, file) |
| [bucket, key] = String.split(URI.parse(s3_url).path, "/", parts: 2) |
| content = File.read!(file) |
| ExAws.request!(ExAws.S3.put_object(bucket, key, content)) |
| %{"filename" => Path.basename(file), "s3_url" => s3_url} |
| else |
| %{ |
| "filename" => Path.basename(file), |
| "local_path" => file |
| } |
| end |
| end) |
|
|
| Map.put(context, :uploaded_file_refs, uploads) |
| end |
|
|
| defp run_import( |
| %{uploaded_file_refs: uploads, imported_site: imported_site, user: user} = context |
| ) do |
| date_range = CSVImporter.date_range(uploads) |
|
|
| {:ok, _job} = |
| CSVImporter.new_import(imported_site, user, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| uploads: uploads, |
| storage: on_ee(do: "s3", else: "local") |
| ) |
|
|
| assert %{success: 1} = Oban.drain_queue(queue: :analytics_imports, with_safety: false) |
|
|
| site_import = Repo.get_by!(SiteImport, site_id: imported_site.id) |
|
|
| assert %SiteImport{source: :csv, status: :completed} = site_import |
|
|
| Map.put(context, :site_import, site_import) |
| end |
|
|
| defp clean_buckets(_context) do |
| on_ee do |
| clean_bucket = fn bucket -> |
| ExAws.S3.list_objects_v2(bucket) |
| |> ExAws.stream!() |
| |> Stream.each(fn objects -> |
| keys = objects |> List.wrap() |> Enum.map(& &1.key) |
| ExAws.request!(ExAws.S3.delete_all_objects(bucket, keys)) |
| end) |
| |> Stream.run() |
| end |
|
|
| clean_bucket.(Plausible.S3.imports_bucket()) |
| clean_bucket.(Plausible.S3.exports_bucket()) |
|
|
| on_exit(fn -> |
| clean_bucket.(Plausible.S3.imports_bucket()) |
| clean_bucket.(Plausible.S3.exports_bucket()) |
| end) |
| else |
| :ok |
| end |
| end |
|
|
| defp pairwise(left, right, f) do |
| assert length(left) == length(right) |
| zipped = Enum.zip(left, right) |
| Enum.map(zipped, fn {left, right} -> f.(left, right) end) |
| end |
|
|
| defp field(results, field) do |
| results |
| |> Enum.map(&Map.fetch!(&1, field)) |
| |> Enum.filter(&is_number/1) |
| end |
|
|
| defp summary(values) do |
| values = Enum.sort(values) |
|
|
| percentile = fn n -> |
| r = n / 100.0 * (length(values) - 1) |
| f = :erlang.trunc(r) |
| lower = Enum.at(values, f) |
| upper = Enum.at(values, f + 1) |
| lower + (upper - lower) * (r - f) |
| end |
|
|
| [ |
| List.first(values), |
| percentile.(25), |
| percentile.(50), |
| percentile.(75), |
| List.last(values) |
| ] |
| end |
| end |
|
|