From 7f228edd2a9bb39d7110f16d5112aab7594dd3d2 Mon Sep 17 00:00:00 2001 From: Barno <32536886+CarolBarno@users.noreply.github.com> Date: Mon, 24 Oct 2022 22:42:13 +0300 Subject: [PATCH] Merge (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added Tier price and currency data to products table (#15366) refs https://github.com/TryGhost/Team/issues/1765 In order to better handle deleted objects in Stripe we want to decouple Members from Stripe. These changes allow us to have the Tier concept completely independent of the Stripe tables, such that the Stripe data can be generated as/when it's needed - which will help to protect against missing data. * 🐛 Removed redirects from search engine indexing (#15617) refs https://github.com/TryGhost/Team/issues/2072 Google is indexing our redirects and storign the redirected content against the redirect URL in search results. This seems to be caused by us using a 302 redirect rather than 301. We don't want to switch to a 301 however, so that we can support the ability to update redirects in the future. * Stored price and currency data on Tiers when creating & editing refs https://github.com/TryGhost/Team/issues/2029 This will allow us to start decoupling the Stripe side of things once we've got the core data stored. We've also add some integrity checks on the incoming monthly_price and yearly_price to ensure they are the same currency. * Fixed up more like this and newsletter clicks table at narrower viewports no issue * Updated engagement bar metrics so when no link it doesn't hover no issue * Added the feedback buttons in the emails (#15619) closes TryGhost/Team#2046 closes TryGhost/Team#2045 - Added feedback buttons markup. - Added feedback links generation. * v5.19.0 * Fix newsletter links (#15621) Added a button for editing links in newsletters after sending to the Post analytics page Co-authored-by: Rishabh * Added ability to handle feedback links from emails (#15622) * Updated test snapshot after bumping Portal (#15623) * Bumped portal package.json to latest version refs https://github.com/TryGhost/Ghost/commit/d381ff87b89285894d7aecd2e2247653ba2c4b8f - portal was bumped to 2.15 but the version in package json didn't get auto-updated * Fixed optional syntax style for jsdoc refs https://jsdoc.app/tags-param.html#optional-parameters-and-default-values - using an equals sign in the type definition is part of the Google Closure syntax but we use the JSDoc syntax in all other places, and tsc detects the different syntax - this commit standardizes the syntax ahead of enforcing a certain style down the line * Bumped `kg-lexical-html-renderer` version no issue - Bumped from 0.0.8 to 0.0.9 * Fixed unnecessary requests with loading comment counts refs https://github.com/TryGhost/Team/issues/2082 - if a site has comments enabled but doesn't use the `comments_count` helper, the comments-count.min.js will still be loaded and it'll send a POST request to Ghost with an empty array of post IDs to fetch - this is unnecessary and we should avoid this extra request for pages that don't need to show comment counts - this commit prevents the comment-counts JS from sending the request if there are no post IDs to fetch * Fixed unnecessary requests with loading comment counts refs https://github.com/TryGhost/Team/issues/2082 - if a site has comments enabled but doesn't use the `comments_count` helper, the comments-count.min.js will still be loaded and it'll send a POST request to Ghost with an empty array of post IDs to fetch - this is unnecessary and we should avoid this extra request for pages that don't need to show comment counts - this commit prevents the comment-counts JS from sending the request if there are no post IDs to fetch * Added handling for unsuccessful comments API requests refs https://github.com/TryGhost/Team/issues/2082 - in the event the API doesn't return a 200 OK, we shouldn't be processing the response from it, as we can end up doing weird things if, for example, an error object is returned * Added handling for unsuccessful comments API requests refs https://github.com/TryGhost/Team/issues/2082 - in the event the API doesn't return a 200 OK, we shouldn't be processing the response from it, as we can end up doing weird things if, for example, an error object is returned * Fixed loading animation for Safari no refs. - the size of the loading animation in Safari wasn't set correctly and didn't always start automatically * Added imageUpload function to Lexical Editor (#15634) no issue - A basic image upload function for testing the image card of the new Lexical Koenig Editor. * Update dependency util to v0.12.5 * Update dependency mocha to v10.1.0 * 🐛 Fixed 404 collection links for new tags closes https://github.com/TryGhost/Ghost/issues/15608 closes https://github.com/TryGhost/Toolbox/issues/437 refs https://github.com/bookshelf/bookshelf/issues/2111 refs https://github.com/knex/knex/issues/1641 - When new tag was attached to the post the tag collection link returned 404 - instead of a collection with one post - The root cause of the issue and it's flaky behavior (sometimes the collection link was returning correctly) was a race condition between event propagation in routing for "tag.attached" event and the post+tag+relations transaction completion - The race condition was happening as the bookshelf-transaction-events plugin was emitting the 'committed' event BEFORE the transaction was committed! * v5.19.1 * Added conversions count and separate analytics page (#15637) fixes https://github.com/TryGhost/Team/issues/2084 - When audience feedback is enabled, we use a single 'conversions' count instead of having separate ones for signups and paid conversions. - The analytics component is separated so we can change it without breaking the existing page. * Fixed PromiseObject related errors thrown from members activity list (#15641) no issue - the `memberRecord` getter on the members activity controller was returning the direct result of `store.findRecord()` which in reality is a `PromiseObject` that is a type of proxy. Although templates handle this OK, any JS code using the object needs to know that it's not a typical type of object and has to use `.get()` for all property access to avoid errors - switched `memberRecord` from a getter to a resource so we have more control over the returned object and loading lifecycle - added a `MemberFetcher` resource class that awaits the result of the store find and only then sets the value meaning we always have a fully resolved model object - being a resource the fetch will only occur when the `member` id property changes, unlike the getter which performed the fetch every time it was accessed * Added feedback events to activity feed (#15639) fixes https://github.com/TryGhost/Team/issues/2051 fixes https://github.com/TryGhost/Team/issues/2052 * Added support for supplying the foreign key constraint name - this allows us to choose the foreign key constraint name when the auto-generated one would be too long * Allowed `constraintName` in schema column spec refs https://github.com/TryGhost/Ghost/commit/0ba3d6df49dea823e3632be90cf00f6d10c6e914 - this is used to indicate the name of the foreign key constraint and so we should let it through the schema checks * Fixed various code nits with schema command utils - de-duped the exports at the bottom if they export the same name as the function - added types to all functions, or fixed existing ones - renamed `table` to `tableBuilder` to represent it better - these should help with code readability and autocomplete in editors * Dropped nullable status on `subscriptions.tier_id` fixes https://github.com/TryGhost/Team/issues/2102 - this column was added with `nullable: true` but it should never be nullable, so we should drop the nullable status whilst it's easy to * Improved jsdoc for permission migration utils - this helps with readability and editor autocomplete * 🐛 Fixed large mailgun recipient data (#15638) fixes https://github.com/TryGhost/Team/issues/2096 When generating the recipient data for emails, the email clicks implementation is resulting in a recipient variable being added called replacement_xxx once for each link containing the same UUID. This generates a lot of unnecessary data overhead for emails, and it turns out that mailgun has a 25MB message limit. We wouldn't have come close if we only included the uuid once. * v5.19.2 * Added design for success and error states for editing newsletter links refs https://github.com/TryGhost/Team/issues/2095 refs https://github.com/TryGhost/Team/issues/2098 - Added success state design for editing links in newsletters - Added error state for invalid URL when editing links in newsletters * Fixed broken `ember-svg-jar` asset viewer no issue - downgraded to `2.3.4` because `2.4.0` is missing the asset viewer assets, see https://github.com/voltidev/ember-svg-jar/issues/233 * Update dependency ember-template-lint to v4.16.0 * Added post_id filter and total to activity feed API (#15650) fixes https://github.com/TryGhost/Team/issues/2091 fixes https://github.com/TryGhost/Team/issues/2089 - Added new fixtures to make testing easier for the activity feed - Improved E2E test coverage of activity feed with separate test file - Added data.post_id filter to enable filtering by events related to a given post - Fixed return types in JSDoc of test agents (TypeScript interprets these as `typeof Agent` if we don't add `InstanceType`) - Added total pagination metadata to activity feed API (to allow a basic type of pagination using filters) * 🐛 Fixed alpha feature visible in new newsletters no issue * Added test to check if feedback buttons are hidden if alpha flag is disabled * v5.19.3 * Update dependency ember-template-lint to v4.16.1 * Refactored Tiers logic into separate package refs https://github.com/TryGhost/Team/issues/2078 This pulls the current Tiers logic into its own package, the persistence part of the work has not been done yet, that will be handled in core, so all bookshelf model specific stuff is kept together. * Supported payment URL in Portal refs https://github.com/TryGhost/Team/issues/2078 As part of decoupling Stripe from the members feature, we are going to be using payment URLs rather than Stripe sepcific session ids and publick keys. This prepares Portal to work with the new system, whilst retainign the existing functionality * Made edit link button always visible refs https://github.com/TryGhost/Team/issues/2067 * Added newsletter links editing explanation text refs https://github.com/TryGhost/Team/issues/2067 * Fixed overflow for newsletter links refs https://github.com/TryGhost/Team/issues/2067 * Updated JSDoc and fixed typos * Fixed typo * Fixed typo * Increased test coverage refs https://github.com/TryGhost/Toolbox/issues/430 - The bonus of using the module exports file is that it also gets included in the test coverage statistics ^_^ * Added test coverage for 'subscribed' transform refs https://github.com/TryGhost/Toolbox/issues/430 - Not having any extra logic in the mapper will allow to have a generalized "mapping" concept for CSV input serialization - This is groundwork for stricter header value filtering on the parsing stage * Added JSDoc to members csv parser refs https://github.com/TryGhost/Toolbox/issues/430 - Typings make developer's life nicer * Removed hidden row mapping in csv parser refs https://github.com/TryGhost/Toolbox/issues/430 refs https://github.com/TryGhost/Ghost/issues/14882 - Having an explicit mappings passed into the members CSV parser makes it easier to control and understand the transforms for package clients - Eventually the parser will receive a strict map with the fields it should parse - skipping all unknown & unmapped fields * Extracted MembersCSVImporter to a builder method refs https://github.com/TryGhost/Toolbox/issues/430 refs https://github.com/TryGhost/Ghost/issues/14882 - The MembersCSVImporter constructor is way to complex and needs refactoring. This complexity makes initialization in tests too bulky and makes tests hard to read. - Having a builder method is a stopgap solution to avoid going into MembersCSVImporter refactoring too deep. * Added future investigation note refs https://github.com/TryGhost/Toolbox/issues/430 - This is a strange hardcoded value that seems like some legacy leftover concept we could do without or improve * Removed unused concept of "status" in importer job refs https://github.com/TryGhost/Toolbox/issues/430 - The job "status" is never anything different than "pending" and never leaves the module itself. It's an outdated concept that only takes up lines of code! * Removed cleaned up use of "Job" object refs https://github.com/TryGhost/Toolbox/issues/430 - Importer code was filled with an unnecessarily complex "job" object that was passed around. It had an "id" property, which confusingly was a path to a file at all times. - Simplified the logic significantly by keeping and passing around the path to a "prepared" members CSV. * Added basic test coverage for perform method refs https://github.com/TryGhost/Toolbox/issues/430 - "perform()" is what gets executed by the import job for both immediate import and "inline job" import. Testing it on granular level will allow to change it with more confidence when introducing strict field mapping rules * Moved header mapping configuration to importer refs https://github.com/TryGhost/Toolbox/issues/430 - To be able to introduce strict mapping rules (exclude unknown fields) we need to control the CSV header mapping on the importer level. This change moves the configuration up from CSV parser to the importer - Also adds tests covering correct inserts for specially treated "subscribed_to_emails" field * Cleaned up test case names * Cleaned up csv parse test suite refs https://github.com/TryGhost/Toolbox/issues/430 - Removed unnecessary "readCSV" leftover code. * Added strict header mapping parsing refs https://github.com/TryGhost/Toolbox/issues/430 - Previously the CSV parser had "map whatever you can and pass on unknown properties further" approach to CSV parsing. This logic has led to unwanted fields leaking through CSV imports - messy, dangerous. - The strict mapping rules act as a "validator" to the user input, only passing through the fields we expect explicitly - safer clean cut solution with no unintended side-effects. * Migrated CSV parser tests to 'assert' no issue - Using native 'assert' module in unit tests is a preferred practice. Should is outdated and is phased out of codebase. * Removed unused error message no issue - The job-related code was ripped out form the importer and this message was just an overlooked leftover * Made running the import outside of job on test env - Allows to write tests for the importer easier when there is a "subscription" or a "product" present * Added strict field mapping to member CSV importer closes https://github.com/TryGhost/Toolbox/issues/430 - The members importer used to import all fields present in the uploaded CSV if the headers match, even if they're not mapped in the UI. This behavior has lead to have misleading consequences and "hidden" features. For example, if the field was present but intentionally left as "Not imported" in the UI the field would still get imported. - Having a strict list of supported import fields also allows for manageable long-term maintenance of the CSV Import API and detect/communicate changes when they happen. - The list of the current default field mapping is: email: 'email', name: 'name', note: 'note', subscribed_to_emails: 'subscribed', created_at: 'created_at', complimentary_plan: 'complimentary_plan', stripe_customer_id: 'stripe_customer_id', labels: 'labels', products: 'products' * Fixed broken CSV importer tests refs https://github.com/TryGhost/Ghost/commit/90768e9985e756a2706508beeb5c50e34516a170 - With introduction of strict field mapping the regression test testing for "imports of not mapped fields" failed. * Added tabs component (#15652) closes TryGhost/Team#2086 * Updated feedback buttons url (#15655) closes TryGhost/Team#2080 - If the post was published and emailed the link leads the user to the post. - If the post was just emailed the link leads the user to the home page. * Remove Grunt from `yarn setup` in Ghost core no issue * Added design for edited state of newsletter links refs https://github.com/TryGhost/Team/issues/2111 - Added indicator that the link in a newsletter has been edited after sending * Wired newsletter link success/error states refs https://github.com/TryGhost/Team/issues/2116 - wires handling of success and error messages on editing a newsletter link - has the update api commented out temporarily to mock the changes * Added sentiment ordering and include for posts (#15657) fixes https://github.com/TryGhost/Team/issues/2090 - This changes how sentiment is exposed in the API. Now it is exposed as a `sentiment` relation, directly on the model (no longer in counts). Internally we still use `count.sentiment`. - Content API users (and themes) can include the 'sentiment' relation and order by sentiment. - Updated Admin to use sentiment instead of count.sentiment * Fixed period filter not appearing on smaller screens on dashboard no ref * Added edited state for newsletter links refs https://github.com/TryGhost/Team/issues/2111 - shows edited in UI next to edited newsletter links in post analytics * Handled no empty sources for attribution table - fixes an edge case where if a site has no unavailable sources in a particular period, it broke the table view as the `Others` data doesn't get fetched * Fixed up the more like this event icons as they were too large refs https://github.com/TryGhost/Team/issues/2106 * Update dependency mocha to v10.1.0 * Fixed permissions for links endpoint (#15656) refs https://github.com/TryGhost/Ghost/commit/5fcf5098a84eea5aed82a412718e6dde8d2b725a - links browse endpoint had permissions switched off unintentionally and was also missing the necessary permissions in fixtures. - enables permissions for browse endpoint and adds migration insert permissions in DB * Update dependency ember-svg-jar to v2.4.1 * Update peter-evans/create-or-update-comment digest to 7305482 * Added permissions for link edit endpoints (#15664) refs https://github.com/TryGhost/Team/issues/2104 - adds edit permissions for links endpoints to fixtures - new `bulkEdit` endpoint will use the permissions and allow fixing newsletter links via Admin * Added `ghost_subscription_id` column to `members_stripe_customers_subscriptions` refs https://github.com/TryGhost/Team/issues/2034 - this table will be used to link Stripe subscriptions to Ghost subscriptions via a foreign key that we add at a later point - this also includes `constraintName` as the auto-generated one would be too long for MySQL 8 * Refactored TiersAPI to use core slug generation refs https://github.com/TryGhost/Team/issues/2078 This removes the burden from the Tier object, and allows us to reuse the existing slug generation implementation we have in our bookshelf models. * Fixed visibility property on Tiers refs https://github.com/TryGhost/Team/issues/2078 This property should be settable to either 'public' or 'none' * Updated Tier to use camelCase property names refs https://github.com/TryGhost/Team/issues/2078 Having to map between snake_case and camelCase was becoming confusing, so this updates the Tier object to exclusively use camelCase, and the snake_case for the API can be handled by the serializer/mapper or at the controller level. * Updated Tier welcomePageURL to be a string rather than URL refs https://github.com/TryGhost/Team/issues/2078 We allow passing in relative URLs for the welcome page, so we need to accept a string for this property. * Fixed trialDays validation to default to 0 refs https://github.com/TryGhost/Team/issues/2078 This retains current functionality * Added error messages for monthly & yearly price validation refs https://github.com/TryGhost/Team/issues/2078 These were missing, and will be refactored into the correct tpl(messages.prop) format * Fixed edit method to accept a string refs https://github.com/TryGhost/Team/issues/2078 This fixes a bug with the edit method where we passed a raw string rather than ObjectID * Fixed add method for TiersAPI refs https://github.com/TryGhost/Team/issues/2078 The check for creating free Tiers needs to be explicit, rather than implicit because the type is optional, we only want to error if it is explicitly passed as "free", not if it is missing as "paid". * Removed garbage test no issue - This test does nothing but occupy the disc space * Moved the resources to the new style refs https://github.com/TryGhost/Team/issues/2117 * Added 'getDefaultProduct' convenience method to product repo refs https://github.com/TryGhost/Team/issues/1869 - There are multiple places in the codebase fetching "default product". The code is slightly divergent in each one of them and has been a source of bugs (like the one referenced). Having the logic captured in one place will allow reducing the code duplication, making code less bug prone, and making testing the modules dependent on the "setDefaultProduct" method easier * Fixed comped tier assignment closes https://github.com/TryGhost/Team/issues/1869 - When there were "archived" tiers in the system the importer incorrectly fetched them instead of only taking "active" ones into account. The "getDefaultProduct" on product repository does exactly that. - Additionally, reusing the "getDefaultProduct" makes testing the importer slightly less complex. * Reused getDefaultProduct where possible refs https://github.com/TryGhost/Ghost/commit/82ed10473b0b2afc100a461129b2178557f588ac refs https://github.com/TryGhost/Team/issues/1869 - getDefaultProduct has unified logic across different places (see refed commit). It is recommended to use instead of writing custom queries prone to mistakes. - Also added more readable name to the possible error message thrown by setComplimentarySubscription * Added button for saving changes made to the link refs https://github.com/TryGhost/Team/issues/2125 * Added support for Tier events refs https://github.com/TryGhost/Team/issues/2078 These events are all required for other parts of the Ghost system to stay in sync. The events on each Tier object will be dispatched by the TierRepository once they've been persisted. TierCreatedEvent - generate Stripe Products & Prices TierNameChangeEvent - update Stripe Products TierPriceChangeEvent - update Stripe Products & Prices TierArchivedEvent - update the Portal settings for visible tiers - disable Stripe Products & Prices TierActivatedEvent - enable Stripe Products & Prices * Exported Tier events from tiers package These are required to be exported so that external code can subscribe to the events. * Made default and editing states the same height refs https://github.com/TryGhost/Team/issues/2067 * Added support for filtering email events by post_id (#15666) refs https://github.com/TryGhost/Team/issues/2093 * Moving the post analytics layout closer to new design - Newsletter clicks module has new header and box - Source attribution module has new header and box - Resources have new headers inside the box - New split layout if there are two modules side-by-side refs https://github.com/TryGhost/Team/issues/2119 * Added endpoint for fixing newsletter links refs https://github.com/TryGhost/Team/issues/2104 - adds new bulk edit endpoint for links, updates all matching link with the current redirect url and update to new url * Added reset for link click count on edit refs https://github.com/TryGhost/Team/issues/2104 When a newsletter link is edited, we reset its click count to 0 to show only the clicks on newly edited links. This is done by only counting the member click events for a link which are greater than its last updated at, so that all previous click events are not counted for the link, but are included in the total count of all links on the page. * Wired link update api on Admin refs https://github.com/TryGhost/Team/issues/2116 - wires link update API on post analytics UI page to save edited links for a newsletter - all links matching the edited link are updated by the API in the backend * Added edited property to links api refs https://github.com/TryGhost/Team/issues/2104 - adds a boolean `edited` property to links api that denotes if the link has been edited * Updated success state for link update on Admin refs https://github.com/TryGhost/Team/issues/2116 - removes extra redundant success state handling * Fixed first and last child padding in edit mode refs https://github.com/TryGhost/Team/issues/2067 * Added missing dependencies for link tracking * Improved responsiveness refs https://github.com/TryGhost/Team/issues/2067 * Deemphasized edited state refs https://github.com/TryGhost/Team/issues/2127 * Fixed missing padding in post analytics header refs https://github.com/TryGhost/Team/issues/2127 * Fixed unrelated elements sharing the same CSS class refs https://github.com/TryGhost/Team/issues/2127 * Vertically aligned icons refs https://github.com/TryGhost/Team/issues/2127 * Fixed explanation text not showing when there's no pagination refs https://github.com/TryGhost/Team/issues/2127 * Added retry button in error state refs https://github.com/TryGhost/Team/issues/2127 * Fixed jumping of links on edit in analytics refs https://github.com/TryGhost/Team/issues/2116 - editing a link caused it to jump in the list as its count is reset to 0 - this forces the order of links same after edit, the new order is only visible on refresh or navigating away and coming back * Added filtered events tables to the analytics page (#15669) closes TryGhost/Team#2087 * Updated edited state font weight for better contrast refs https://github.com/TryGhost/Team/issues/2127 * Removed unnecessary padding refs https://github.com/TryGhost/Team/issues/2127 * Updated save button handling for links refs https://github.com/TryGhost/Team/issues/2116 * Fixed row hover effects in dark mode refs https://github.com/TryGhost/Team/issues/2127 * Changed explainer text color and icon to deemphasize it refs https://github.com/TryGhost/Team/issues/2127 * Refined link edit flow on Admin refs https://github.com/TryGhost/Team/issues/2116 * Fixed cached redirects for edited newsletter links refs https://github.com/TryGhost/Team/issues/2135 The email link redirects on Pro are cached as 302 redirects in Varnish, so we're missing further clicks after the first one for each member, until the cache is invalidated. This change invalidates cache on link edits to ensure that we correctly redirect members to updated link everytime * Fixed lint * Improved URL syncing between Admin and Explore (#15640) no issue Improve the route communication between Ghost Admin and Ghost Explore to reflect route changes in the URL and correctly navigate to Explore sub routes * Improved readability of unparse test refs https://github.com/TryGhost/Team/issues/1076 - The column parameter has default behavior attached to it, so it is best to have it mentioned and used in an explicit way. * Migrated unparse test suite to assert refs https://github.com/TryGhost/Team/issues/1076 - The 'should' assertion library is deprecated. Native 'assert' is the recommended lib to use - Migrating this bit of code allows to remove the should's "utils" folder. Less code, yey! * Removed test "utils" folder refs https://github.com/TryGhost/Team/issues/1076 - The members-csv package does not use "should" for assertions anymore, so the accompanying "utils" lib can go away now * Added 100% unit test coverage to unparse refs https://github.com/TryGhost/Team/issues/1076 - 100% is the golden standard. Easy to keep it this way once there * Changed members export to contain tiers refs https://github.com/TryGhost/Team/issues/1076 - The "products" is a legacy term for what is now "tiers" since multiple tiers feature introduction in https://github.com/TryGhost/Ghost/releases/tag/v4.39.0 - Note, the "tiers" is a field meant for informational purposes and cannot be imported back into Ghost site. * Fixed date format for link bulk edit refs https://github.com/TryGhost/Team/issues/2104 - updates date format for `updated_at` to use the right DB date format * Refined link edit cancellation on analytics page refs https://github.com/TryGhost/Team/issues/2116 - ignores blur events when link is edited via keypress * Added e2e tests for `post.edited` webhook (#15625) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. * Added e2e tests for `member.edited` webhook (#15620) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. * Added e2e tests for `page.edited` webhook (#15627) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. * Updated explainer text design refs https://github.com/TryGhost/Team/issues/2127 * Added e2e test for `post.unpublished` webhook (#15628) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. * Fixed test snapshots refs https://github.com/TryGhost/Ghost/commit/5b283930f065ffcc037eeceadd83455f4d09a731 * Quick tweak to max height when there are columns side-by-side refs https://github.com/TryGhost/Team/issues/2118 * Added e2e tests for `page.unpublished` webhook (#15613) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. * Updated webhook test snapshots refs: https://github.com/TryGhost/Ghost/commit/717a27c85c340646b6d2042b5a9dcbe7fce6e166 refs: https://github.com/TryGhost/Ghost/commit/6380b82793e759a68999cb342c198d9d38fa68b1 - The snapshots just needed updating * Better matching the pagination styles across modules for post analytics - Careful of flags, having different paginations depending on those - Little stylistic adjustment to how pagination is shown on engagement bar - Quite a few feature flag checks to make sure the right style is showing refs https://github.com/TryGhost/Team/issues/2136 * Added importer for custom theme settings (#15596) closes: https://github.com/TryGhost/Ghost/issues/15542 - custom theme settings were not reinstated on import - importing custom theme settings for the current active theme requires the theme be re-activated * Moved the newsletter settings option for audience feedback based on feedback refs https://github.com/TryGhost/Team/issues/2124 * ✨ Allowed fixing newsletter links (#15672) refs https://github.com/TryGhost/Team/issues/2116 - allows site owners to edit a link in a post that has already been sent out, fixing any typos or other mistakes - resets click counter for the edited link back to 0 so site owners can see the clicks on new link, doesn't change the overall click count * v5.20.0 * Changing the metric to positive feedback rather than more like this refs https://github.com/TryGhost/Team/issues/2138 * Making avatar initials smaller in the engagement bar refs https://github.com/TryGhost/Team/issues/2139 * Added new members filters and refactored filters (#15667) fixes https://github.com/TryGhost/Team/issues/2112 - Removed a bit of duplicate code across templates and components that was used to handle filters - Updated filter objects to contain information about the filter - Added resource filters that are able to select a single resource, which can be used in columns - Filters can now define columns by themselves. Not all columns already make use of this functionality, but we can move those over later (cleanup: https://github.com/TryGhost/Team/issues/2133) - The filter definitions became quite long. We should move them to separate files in the future: https://github.com/TryGhost/Team/issues/2134 - Filters can now have custom NQL parsing - Improved support for parsing recursive or grouped NQL queries - Added support for filtering members by feedback * Improved post activity feed table no issue - Fixed counter at the bottom - Fixed forward > backward not working * Add in dummy rows and empty states to the engagement bar refs https://github.com/TryGhost/Team/issues/2130 * Removed bluebird from fixture-manager.js (#15629) refs: https://github.com/TryGhost/Ghost/issues/14882 - Removing bluebird specific methods in favour of the Ghost sequence method so we can remove the bluebird dependency Co-authored-by: Hannah Wolfe * Added pie chart for activity feed (#15673) closes TryGhost/Team#2088 - Added pie chart to feedback event - Added `negative_feedback` field to response from BE * Removed bluebird from `fixture-utils.js` (#15626) refs: https://github.com/TryGhost/Ghost/issues/14882 - Opted to use the in-house `sequence` function when refactoring Bluebird's `Promise.each` to avoid deadlock issues (see https://github.com/TryGhost/Ghost/commit/734ef66e6ce9868fe2143046cb56cdc8e4216ff5). -It's hard to know without tonnes of context if any `Promise.each` are safe to refactor to `Promise.all`. * 🐛 Fixed redirect to signin modal not shown when logged out (#15522) fixes: https://github.com/TryGhost/Ghost/issues/15291 - An attempt to improve re-authenticate modal toggle - show re-authenticate modal every time user save (ctrl/cmd + s) - An attempt to fix redirection when user re-login on different tab. Prevent redirection to sign-in page since the user already logged in on another tab. - Re-enable `editor` test on `authentication-test.js` * 🐛 Fixed missing unsaved changes modal for member newsletters (#15564) closes: https://github.com/TryGhost/Ghost/issues/15507 - manually handle relationship changes detection labels and newsletters - add `dirtyAttributes` controller property - return newsletters and labels dirty attributes status * Lock file maintenance * Update dependency eslint to v8.26.0 * Update dependency ember-auto-import to v2.4.3 * Update dependency inquirer to v8.2.5 * Update dependency concurrently to v7.5.0 * Update dependency @babel/plugin-proposal-decorators to v7.19.6 * Update dependency ember-svg-jar to v2.4.2 * Updated @tryghost dependencies (#15631) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Update dependency html-validate to v7.7.0 * Released Portal v2.16.0 * Fixed default feedback enabled when flag is disabled (#15660) fixes https://github.com/TryGhost/Team/issues/2114 fixes https://github.com/TryGhost/Team/issues/2115 When a new newsletter is created, the frontend will send feedback_enabled to true. We'll catch this in the backend and don't allow setting feedback_enabled to true when audience_feedback flag is disabled. This is also handled for editing newsletters. To fix this in existing sites, I added a migration that disables feedback for all sites (since this is an alpha feature). Once we'll release the feature later, it will be disabled for existing newsletters, just like expected. * Added email sent events (#15682) fixes https://github.com/TryGhost/Team/issues/2137 For the analytics page, we need the sent events to show up immediately after sending an email. Otherwise we need to wait for emails to be marked as received (which takes too long) before being able to show them on the analytics page. This adds the email_sent_event, which is hidden by default everywhere and used on the analytics page. * Added dark mode styles to the post analytics table no issue * Rearranged the markup so the empty state shows ok without pie chart refs https://github.com/orgs/TryGhost/projects/60 * Restricted members importer to ignore "products" column refs https://github.com/TryGhost/Team/issues/1076 refs https://github.com/TryGhost/Ghost/commit/70229e4fd328b3890f4cd02246b0ba77b510ff48#diff-b67ecda91b5bd79c598e5c5a9ec2ccf28dbfab6a924b21352273865e07cd7ceaR57 - The "products" column has not been doing any logic anything since at least 5.20.0 (see refed commit). The concept of columns in the export file was mostly there for analytical/data filtering reasons - so the user could analyze their exports. CSV was never a good suite for relational data that "products" (or now tiers) represent - The "tiers" column will still be present in the exported CSV file, but there is not going to be any logic attached to it. - The only columns that can effect the "tiers" state of the member are: "complimentary_plan" (assign default tier to the member) and "stripe_customer_id" (pulls in subscription/tier data from Stripe) * Cleaned up leftover "product" variable naming refs https://github.com/TryGhost/Team/issues/1076 - "product" in the context of members has been deprecated since introduction of "tiers" * Improved the error modals for audience feedback and newsletter unsubscribe - Main goal to improve modal when audience feedback fails - Felt right to also improve the newsletter unsubscribe issue, too - Makes this more pleasing to read and look at, nothing fancy refs https://github.com/TryGhost/Team/issues/2081 * Released Portal v2.17.0 * Released Portal v2.18.0 * Update the portal version number no issue * Fixed snapshot tests refs https://github.com/TryGhost/Ghost/commit/30ecaef329570b49f33284adf8ee0f1d6bb7f72c * Added e2e tests for `post.unscheduled` webhook (#15675) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. * Added e2e tests for `post.published.edited` webhook (#15642) refs: https://github.com/TryGhost/Ghost/issues/15537 - snapshot test created to add confidence to webhook stability and increase overall test coverage. Co-authored-by: Kritika Sharma * Renamed the member links to be something simpler on Post Analytics page refs https://github.com/TryGhost/Team/issues/2147 * Updated webhook test snapshots refs: https://github.com/TryGhost/Ghost/commit/26d049911c1c3179ed9cbab415b5ec5b9215d78f refs: https://github.com/TryGhost/Ghost/commit/8c2f832573f6e4b38a548c88ca6759e29e0aa38f - snapshots fell behind between the two referenced commits, and needed updating Co-authored-by: Fabien 'egg' O'Carroll Co-authored-by: James Morris Co-authored-by: Elena Baidakova Co-authored-by: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Djordje Vlaisavljevic Co-authored-by: Rishabh Co-authored-by: Daniel Lockyer Co-authored-by: Ronald Langeveld Co-authored-by: Peter Zimon Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Naz Co-authored-by: Simon Backx Co-authored-by: Kevin Ansfield Co-authored-by: Kevin Ansfield Co-authored-by: Sam Lord Co-authored-by: Rishabh Garg Co-authored-by: Aileen Booker Co-authored-by: Shubhadeep Das Co-authored-by: illiteratewriter Co-authored-by: Shashank Gupta Co-authored-by: Samprit JC Co-authored-by: Halldor Thorhallsson Co-authored-by: Hannah Wolfe Co-authored-by: Hakim Razalan Co-authored-by: Dominik Picheta Co-authored-by: Kritika Sharma Co-authored-by: Kritika Sharma --- .github/workflows/migration-review.yml | 2 +- ghost/adapter-manager/package.json | 2 +- .../admin/app/components/gh-explore-iframe.js | 2 +- .../app/components/gh-resource-select.hbs | 25 +- .../app/components/gh-resource-select.js | 71 +- .../app/components/koenig-lexical-editor.js | 36 +- .../source-attribution-table.js | 2 +- .../app/components/member/activity-feed.hbs | 2 +- .../app/components/member/activity-feed.js | 3 +- .../members-activity/event-type-filter.js | 6 + .../app/components/members/filter-value.hbs | 75 +- .../app/components/members/filter-value.js | 51 +- ghost/admin/app/components/members/filter.hbs | 3 +- ghost/admin/app/components/members/filter.js | 719 +++-- .../components/members/list-item-column.hbs | 62 +- .../components/members/list-item-column.js | 8 + .../modals/newsletters/edit/settings.hbs | 27 +- .../admin/app/components/posts/analytics.hbs | 180 +- ghost/admin/app/components/posts/analytics.js | 17 + .../posts/feedback-events-chart.hbs | 7 + .../components/posts/feedback-events-chart.js | 98 + .../feedback-events-tooltip-template.hbs | 19 + .../app/components/posts/links-table.hbs | 164 +- .../admin/app/components/posts/links-table.js | 52 +- .../app/components/posts/old-analytics.hbs | 161 ++ .../app/components/posts/old-analytics.js | 254 ++ .../components/posts/post-activity-feed.hbs | 148 ++ .../components/posts/post-activity-feed.js | 70 + ghost/admin/app/components/tabs/tab-panel.hbs | 11 + ghost/admin/app/components/tabs/tab-panel.js | 13 + ghost/admin/app/components/tabs/tab.hbs | 13 + ghost/admin/app/components/tabs/tab.js | 24 + ghost/admin/app/components/tabs/tabs.hbs | 30 + ghost/admin/app/components/tabs/tabs.js | 65 + ghost/admin/app/controllers/editor.js | 11 + ghost/admin/app/controllers/explore.js | 2 +- ghost/admin/app/controllers/lexical-editor.js | 11 + ghost/admin/app/controllers/member.js | 57 + .../admin/app/controllers/members-activity.js | 17 +- ghost/admin/app/controllers/members.js | 55 +- .../app/helpers/activity-feed-fetcher.js | 140 + ghost/admin/app/helpers/member-fetcher.js | 26 + .../admin/app/helpers/members-event-filter.js | 8 +- ghost/admin/app/helpers/parse-member-event.js | 29 +- ghost/admin/app/index.html | 2 +- ghost/admin/app/models/post.js | 5 +- ghost/admin/app/routes/authenticated.js | 2 +- ghost/admin/app/routes/explore.js | 10 + ghost/admin/app/routes/explore/index.js | 25 +- ghost/admin/app/routes/member.js | 43 +- .../app/services/member-import-validator.js | 2 +- ghost/admin/app/services/session.js | 23 + ghost/admin/app/styles/app-dark.css | 40 +- .../app/styles/components/power-select.css | 23 + ghost/admin/app/styles/layouts/content.css | 558 +++- ghost/admin/app/styles/layouts/dashboard.css | 5 +- ghost/admin/app/styles/layouts/members.css | 12 + ghost/admin/app/templates/members.hbs | 2 +- ghost/admin/app/templates/posts/analytics.hbs | 6 +- ghost/admin/app/templates/settings/labs.hbs | 13 - ghost/admin/ember-cli-build.js | 2 +- ghost/admin/package.json | 20 +- .../public/assets/icons/empty-clicked.svg | 12 + .../public/assets/icons/empty-conversion.svg | 11 + .../public/assets/icons/empty-feedback.svg | 3 + .../public/assets/icons/empty-opened.svg | 12 + .../admin/public/assets/icons/empty-sent.svg | 11 + ...nt-less-like-this--feature-attribution.svg | 3 + .../assets/icons/event-less-like-this.svg | 3 + ...nt-more-like-this--feature-attribution.svg | 3 + .../assets/icons/event-more-like-this.svg | 3 + .../tests/acceptance/authentication-test.js | 48 +- .../tests/acceptance/members/filter-test.js | 17 + .../integration/components/tabs/tabs-test.js | 105 + ghost/api-framework/package.json | 6 +- .../package.json | 2 +- .../lib/AudienceFeedbackService.js | 35 +- ghost/audience-feedback/package.json | 4 +- .../test/AudienceFeedbackService.test.js | 45 + ghost/audience-feedback/test/hello.test.js | 10 - ghost/bootstrap-socket/package.json | 2 +- ghost/constants/package.json | 2 +- ghost/core/core/frontend/public/robots.txt | 1 + .../src/comment-counts/js/comment-counts.js | 8 + ghost/core/core/server/api/endpoints/links.js | 34 +- .../core/core/server/api/endpoints/members.js | 5 +- .../core/server/api/endpoints/posts-public.js | 2 +- ghost/core/core/server/api/endpoints/posts.js | 3 +- .../utils/serializers/input/posts.js | 22 +- .../utils/serializers/output/index.js | 4 + .../utils/serializers/output/links.js | 5 + .../output/mappers/activity-feed-events.js | 62 +- .../serializers/output/mappers/emails.js | 2 +- .../utils/serializers/output/mappers/posts.js | 21 +- .../serializers/output/mappers/snippets.js | 4 +- .../utils/serializers/output/members.js | 11 +- .../importers/data/custom-theme-settings.js | 81 + .../importer/importers/data/data-importer.js | 2 + .../data/migrations/utils/permissions.js | 59 +- ...-02-20-25-add-columns-to-products-table.js | 19 + ...9-02-20-52-backfill-new-product-columns.js | 37 + .../2022-10-18-05-39-drop-nullable-tier-id.js | 3 + ...dd-ghost-subscription-id-column-to-mscs.js | 10 + ...10-19-11-17-add-link-browse-permissions.js | 10 + ...2-10-20-02-52-add-link-edit-permissions.js | 10 + ...22-10-24-07-23-disable-feedback-enabled.js | 20 + .../core/core/server/data/schema/commands.js | 155 +- .../data/schema/fixtures/fixture-manager.js | 30 +- .../server/data/schema/fixtures/fixtures.json | 21 +- ghost/core/core/server/data/schema/schema.js | 23 +- .../server/models/base/plugins/actions.js | 2 +- .../core/server/models/email-recipient.js | 14 + ghost/core/core/server/models/email.js | 6 +- .../core/server/models/member-click-event.js | 24 + .../models/member-paid-subscription-event.js | 15 + ghost/core/core/server/models/member.js | 6 + ghost/core/core/server/models/post.js | 35 +- ghost/core/core/server/models/redirect.js | 1 + .../services/audience-feedback/index.js | 9 +- .../bulk-email/bulk-email-processor.js | 6 +- .../LinkRedirectRepository.js | 21 +- .../link-tracking/PostLinkRepository.js | 28 +- .../server/services/link-tracking/index.js | 4 +- .../server/services/mega/email-preview.js | 2 +- .../server/services/mega/feedback-buttons.js | 69 + .../services/mega/post-email-serializer.js | 42 +- .../core/server/services/mega/template.js | 3 + .../core/core/server/services/members/api.js | 3 +- .../core/server/services/newsletters/index.js | 4 +- .../server/services/newsletters/service.js | 12 +- .../core/server/services/url/UrlGenerator.js | 6 +- .../server/web/api/endpoints/admin/routes.js | 1 + ghost/core/core/shared/config/defaults.json | 2 +- ghost/core/core/shared/labs.js | 6 +- ghost/core/package.json | 32 +- .../__snapshots__/activity-feed.test.js.snap | 750 ++++++ .../__snapshots__/email-previews.test.js.snap | 20 +- .../admin/__snapshots__/links.test.js.snap | 572 ++++ .../members-exporter.test.js.snap | 14 +- .../members-newsletters.test.js.snap | 8 +- .../admin/__snapshots__/members.test.js.snap | 92 +- .../admin/__snapshots__/posts.test.js.snap | 76 +- .../admin/__snapshots__/settings.test.js.snap | 2 +- .../admin/__snapshots__/stats.test.js.snap | 4 +- .../test/e2e-api/admin/activity-feed.test.js | 243 ++ ghost/core/test/e2e-api/admin/links.test.js | 228 ++ .../e2e-api/admin/members-exporter.test.js | 26 +- ghost/core/test/e2e-api/admin/members.test.js | 68 +- .../test/e2e-api/admin/posts-legacy.test.js | 4 +- ghost/core/test/e2e-api/admin/utils.js | 3 +- .../__snapshots__/webhooks.test.js.snap | 40 +- .../test/e2e-frontend/default_routes.test.js | 3 +- .../__snapshots__/members.test.js.snap | 70 + .../__snapshots__/pages.test.js.snap | 458 +++- .../__snapshots__/posts.test.js.snap | 1303 ++++++++-- .../__snapshots__/tags.test.js.snap | 4 +- ghost/core/test/e2e-webhooks/members.test.js | 46 + ghost/core/test/e2e-webhooks/pages.test.js | 107 +- ghost/core/test/e2e-webhooks/posts.test.js | 207 ++ .../integration/migrations/migration.test.js | 3 +- .../test/integration/services/mega.test.js | 7 +- .../api/admin/members-importer.test.js | 3 +- ghost/core/test/regression/api/admin/utils.js | 3 +- .../__snapshots__/ghost_head.test.js.snap | 8 +- .../frontend/helpers/helper-test-utils.js | 12 + .../schema/fixtures/fixture-manager.test.js | 2 +- .../unit/server/data/schema/integrity.test.js | 4 +- .../unit/server/data/schema/schema.test.js | 1 + .../server/models/member-click-event.test.js | 23 + .../member-paid-subscription-event.test.js | 23 + .../link-tracking/PostLinkRepository.test.js | 56 + .../mega/post-email-serializer.test.js | 172 +- ghost/core/test/utils/e2e-framework.js | 12 +- ghost/core/test/utils/fixture-utils.js | 171 +- .../fixtures/csv/members-with-mappings.csv | 2 +- .../test/utils/fixtures/data-generator.js | 99 +- ghost/core/test/utils/fixtures/fixtures.json | 21 +- .../package.json | 6 +- ghost/domain-events/package.json | 2 +- .../package.json | 2 +- ghost/email-analytics-service/package.json | 4 +- ghost/email-content-generator/package.json | 2 +- ghost/express-dynamic-redirects/package.json | 2 +- ghost/html-to-plaintext/package.json | 2 +- ghost/job-manager/package.json | 2 +- ghost/link-redirects/lib/LinkRedirect.js | 3 + .../lib/LinkRedirectsService.js | 10 + ghost/link-redirects/package.json | 2 +- ghost/link-replacer/package.json | 2 +- .../lib/LinkClickTrackingService.js | 111 +- ghost/link-tracking/package.json | 9 +- .../test/LinkClickTrackingService.test.js | 49 +- ghost/magic-link/lib/MagicLink.js | 6 +- ghost/magic-link/package.json | 4 +- ghost/mailgun-client/lib/mailgun-client.js | 2 +- ghost/mailgun-client/package.json | 4 +- ghost/member-analytics-service/package.json | 4 +- ghost/member-attribution/package.json | 2 +- ghost/member-events/package.json | 2 +- ghost/members-analytics-ingress/package.json | 2 +- ghost/members-api/lib/MembersAPI.js | 4 +- ghost/members-api/lib/repositories/event.js | 151 +- ghost/members-api/lib/repositories/member.js | 19 +- ghost/members-api/lib/repositories/product.js | 51 +- ghost/members-api/package.json | 4 +- ghost/members-api/test/hello.test.js | 10 - .../test/unit/lib/repositories/member.test.js | 48 + .../unit/lib/repositories/product.test.js | 28 + ghost/members-csv/lib/parse.js | 38 +- ghost/members-csv/lib/unparse.js | 4 +- ghost/members-csv/package.json | 2 +- .../fixtures/subscribed-to-emails-header.csv | 3 + ghost/members-csv/test/parse.test.js | 218 +- ghost/members-csv/test/unparse.test.js | 88 +- ghost/members-csv/test/utils/assertions.js | 11 - ghost/members-csv/test/utils/index.js | 11 - ghost/members-csv/test/utils/overrides.js | 10 - ghost/members-events-service/package.json | 2 +- ghost/members-importer/lib/importer.js | 108 +- ghost/members-importer/package.json | 4 +- .../test/fixtures/member-csv-export.csv | 3 + .../fixtures/subscribed-to-emails-header.csv | 3 + ghost/members-importer/test/importer.test.js | 300 ++- ghost/members-ssr/package.json | 4 +- ghost/minifier/package.json | 6 +- ghost/mw-api-version-mismatch/package.json | 2 +- ghost/mw-cache-control/package.json | 2 +- ghost/mw-error-handler/package.json | 6 +- ghost/mw-session-from-token/package.json | 2 +- ghost/mw-update-user-last-seen/package.json | 2 +- ghost/mw-vhost/package.json | 2 +- ghost/oembed-service/package.json | 4 +- ghost/offers/package.json | 2 +- ghost/package-json/package.json | 4 +- ghost/payments/package.json | 2 +- ghost/portal/package.json | 2 +- .../src/components/pages/FeedbackPage.js | 51 +- .../src/components/pages/UnsubscribePage.js | 24 +- ghost/portal/src/utils/api.js | 3 + ghost/referrers/package.json | 2 +- ghost/security/package.json | 2 +- ghost/session-service/package.json | 2 +- ghost/settings-path-manager/package.json | 4 +- ghost/staff-service/package.json | 2 +- ghost/stats-service/package.json | 2 +- ghost/stripe/lib/StripeAPI.js | 2 +- ghost/stripe/lib/WebhookManager.js | 12 +- ghost/stripe/package.json | 4 +- ghost/tiers/.eslintrc.js | 6 + ghost/tiers/README.md | 23 + ghost/tiers/index.js | 16 + ghost/tiers/lib/InMemoryTierRepository.js | 63 + ghost/tiers/lib/Tier.js | 485 ++++ ghost/tiers/lib/TierActivatedEvent.js | 30 + ghost/tiers/lib/TierArchivedEvent.js | 30 + ghost/tiers/lib/TierCreatedEvent.js | 30 + ghost/tiers/lib/TierNameChangeEvent.js | 30 + ghost/tiers/lib/TierPriceChangeEvent.js | 30 + ghost/tiers/lib/TiersAPI.js | 146 ++ ghost/tiers/package.json | 30 + ghost/tiers/test/.eslintrc.js | 6 + ghost/tiers/test/Tier.test.js | 255 ++ ghost/tiers/test/TiersAPI.test.js | 75 + ghost/tiers/test/index.test.js | 18 + ghost/update-check-service/package.json | 6 +- ghost/verification-trigger/package.json | 2 +- .../package.json | 2 +- package.json | 2 +- yarn.lock | 2307 +++++++++-------- 269 files changed, 12116 insertions(+), 2875 deletions(-) create mode 100644 ghost/admin/app/components/posts/feedback-events-chart.hbs create mode 100644 ghost/admin/app/components/posts/feedback-events-chart.js create mode 100644 ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs create mode 100644 ghost/admin/app/components/posts/old-analytics.hbs create mode 100644 ghost/admin/app/components/posts/old-analytics.js create mode 100644 ghost/admin/app/components/posts/post-activity-feed.hbs create mode 100644 ghost/admin/app/components/posts/post-activity-feed.js create mode 100644 ghost/admin/app/components/tabs/tab-panel.hbs create mode 100644 ghost/admin/app/components/tabs/tab-panel.js create mode 100644 ghost/admin/app/components/tabs/tab.hbs create mode 100644 ghost/admin/app/components/tabs/tab.js create mode 100644 ghost/admin/app/components/tabs/tabs.hbs create mode 100644 ghost/admin/app/components/tabs/tabs.js create mode 100644 ghost/admin/app/helpers/activity-feed-fetcher.js create mode 100644 ghost/admin/app/helpers/member-fetcher.js create mode 100644 ghost/admin/app/routes/explore.js create mode 100644 ghost/admin/public/assets/icons/empty-clicked.svg create mode 100644 ghost/admin/public/assets/icons/empty-conversion.svg create mode 100644 ghost/admin/public/assets/icons/empty-feedback.svg create mode 100644 ghost/admin/public/assets/icons/empty-opened.svg create mode 100644 ghost/admin/public/assets/icons/empty-sent.svg create mode 100644 ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg create mode 100644 ghost/admin/public/assets/icons/event-less-like-this.svg create mode 100644 ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg create mode 100644 ghost/admin/public/assets/icons/event-more-like-this.svg create mode 100644 ghost/admin/tests/integration/components/tabs/tabs-test.js create mode 100644 ghost/audience-feedback/test/AudienceFeedbackService.test.js delete mode 100644 ghost/audience-feedback/test/hello.test.js create mode 100644 ghost/core/core/server/api/endpoints/utils/serializers/output/links.js create mode 100644 ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js create mode 100644 ghost/core/core/server/services/mega/feedback-buttons.js create mode 100644 ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap create mode 100644 ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap create mode 100644 ghost/core/test/e2e-api/admin/activity-feed.test.js create mode 100644 ghost/core/test/e2e-api/admin/links.test.js create mode 100644 ghost/core/test/unit/frontend/helpers/helper-test-utils.js create mode 100644 ghost/core/test/unit/server/models/member-click-event.test.js create mode 100644 ghost/core/test/unit/server/models/member-paid-subscription-event.test.js create mode 100644 ghost/core/test/unit/server/services/link-tracking/PostLinkRepository.test.js delete mode 100644 ghost/members-api/test/hello.test.js create mode 100644 ghost/members-api/test/unit/lib/repositories/product.test.js create mode 100644 ghost/members-csv/test/fixtures/subscribed-to-emails-header.csv delete mode 100644 ghost/members-csv/test/utils/assertions.js delete mode 100644 ghost/members-csv/test/utils/index.js delete mode 100644 ghost/members-csv/test/utils/overrides.js create mode 100644 ghost/members-importer/test/fixtures/member-csv-export.csv create mode 100644 ghost/members-importer/test/fixtures/subscribed-to-emails-header.csv create mode 100644 ghost/tiers/.eslintrc.js create mode 100644 ghost/tiers/README.md create mode 100644 ghost/tiers/index.js create mode 100644 ghost/tiers/lib/InMemoryTierRepository.js create mode 100644 ghost/tiers/lib/Tier.js create mode 100644 ghost/tiers/lib/TierActivatedEvent.js create mode 100644 ghost/tiers/lib/TierArchivedEvent.js create mode 100644 ghost/tiers/lib/TierCreatedEvent.js create mode 100644 ghost/tiers/lib/TierNameChangeEvent.js create mode 100644 ghost/tiers/lib/TierPriceChangeEvent.js create mode 100644 ghost/tiers/lib/TiersAPI.js create mode 100644 ghost/tiers/package.json create mode 100644 ghost/tiers/test/.eslintrc.js create mode 100644 ghost/tiers/test/Tier.test.js create mode 100644 ghost/tiers/test/TiersAPI.test.js create mode 100644 ghost/tiers/test/index.test.js diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml index fcaffdf4bd5..97401fca189 100644 --- a/.github/workflows/migration-review.yml +++ b/.github/workflows/migration-review.yml @@ -11,7 +11,7 @@ jobs: if: github.repository_owner == 'TryGhost' name: Create checklist comment steps: - - uses: peter-evans/create-or-update-comment@6fcd282399b3c9ad0bd9bd8025b8fb2c18b085dd + - uses: peter-evans/create-or-update-comment@73054821735f5750dfe2ed26313889fde739e425 with: issue-number: ${{ github.event.pull_request.number }} body: | diff --git a/ghost/adapter-manager/package.json b/ghost/adapter-manager/package.json index 04d9616a7cc..6f6b83f4cdc 100644 --- a/ghost/adapter-manager/package.json +++ b/ghost/adapter-manager/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, diff --git a/ghost/admin/app/components/gh-explore-iframe.js b/ghost/admin/app/components/gh-explore-iframe.js index 3a50c68e3eb..446e2c63d84 100644 --- a/ghost/admin/app/components/gh-explore-iframe.js +++ b/ghost/admin/app/components/gh-explore-iframe.js @@ -45,6 +45,6 @@ export default class GhExploreIframe extends Component { } _handleSiteDataUpdate(data) { - this.explore.siteData = data.siteData; + this.explore.siteData = data?.siteData ?? {}; } } diff --git a/ghost/admin/app/components/gh-resource-select.hbs b/ghost/admin/app/components/gh-resource-select.hbs index 01417f6418e..ae6524352a4 100644 --- a/ghost/admin/app/components/gh-resource-select.hbs +++ b/ghost/admin/app/components/gh-resource-select.hbs @@ -1,14 +1,19 @@ - - {{resource.name}} - + {{resource.title}} + diff --git a/ghost/admin/app/components/gh-resource-select.js b/ghost/admin/app/components/gh-resource-select.js index cbeb446f449..9bc2f08f59c 100644 --- a/ghost/admin/app/components/gh-resource-select.js +++ b/ghost/admin/app/components/gh-resource-select.js @@ -1,5 +1,10 @@ import Component from '@glimmer/component'; -import {action} from '@ember/object'; +import {A} from '@ember/array'; +import {action, get} from '@ember/object'; +import { + defaultMatcher, + filterOptions +} from 'ember-power-select/utils/group-utils'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; @@ -13,6 +18,45 @@ export default class GhResourceSelect extends Component { return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; } + get searchField() { + return this.args.searchField === undefined ? 'name' : this.args.searchField; + } + + @action + searchAndSuggest(term, select) { + return this.searchAndSuggestTask.perform(term, select); + } + + @task + *searchAndSuggestTask(term) { + let newOptions = this.flatOptions.toArray(); + + if (term.length === 0) { + return newOptions; + } + + // todo: we can do actual filtering on posts here (allow searching when we have lots and lots of posts) + yield undefined; + + newOptions = this._filter(A(newOptions), term); + + return newOptions; + } + + get matcher() { + return this.args.matcher || defaultMatcher; + } + + _filter(options, searchText) { + let matcher; + if (this.searchField) { + matcher = (option, text) => this.matcher(get(option, this.searchField), text); + } else { + matcher = (option, text) => this.matcher(option, text); + } + return filterOptions(options || [], searchText, matcher); + } + constructor() { super(...arguments); this.fetchOptionsTask.perform(); @@ -38,9 +82,12 @@ export default class GhResourceSelect extends Component { return options; } - get selectedOptions() { - const resources = this.args.resources || []; - return this.flatOptions.filter(option => resources.find(resource => resource.id === option.id)); + get selectedOption() { + if (this.args.resource.title) { + return this.args.resource; + } + const resource = this.args.resource ?? {}; + return this.flatOptions.find(option => resource.id === option.id); } @action @@ -55,16 +102,20 @@ export default class GhResourceSelect extends Component { return 'Select a page/post'; } + get searchPlaceholderText() { + if (this.args.type === 'email') { + return 'Search emails'; + } + return 'Search posts/pages'; + } + @task *fetchOptionsTask() { const options = yield []; if (this.args.type === 'email') { const posts = yield this.store.query('post', {filter: '(status:published,status:sent)+newsletter_id:-null', limit: 'all'}); - options.push({ - groupName: 'Emails', - options: posts.map(mapResource) - }); + options.push(...posts.map(mapResource)); this._options = options; return; } @@ -74,8 +125,8 @@ export default class GhResourceSelect extends Component { function mapResource(resource) { return { - name: resource.title, - id: resource.id + id: resource.id, + title: resource.title }; } diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js index 4aa23059f99..05a6660a201 100644 --- a/ghost/admin/app/components/koenig-lexical-editor.js +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/ember'; import Component from '@glimmer/component'; import React, {Suspense} from 'react'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; @@ -108,11 +109,44 @@ export default class KoenigLexicalEditor extends Component { } ReactComponent = () => { + const [uploadProgressPercentage] = React.useState(0); // not in use right now, but will need to decide how to handle the percentage state and pass to the Image Cards + + // const uploadProgress = (event) => { + // const percentComplete = (event.loaded / event.total) * 100; + // setUploadProgressPercentage(percentComplete); + // }; + + async function imageUploader(files) { + function uploadToUrl(formData, url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + // xhr.upload.onprogress = (event) => { + // uploadProgress(event); + // }; + xhr.onload = () => resolve(xhr.response); + xhr.onerror = () => reject(xhr.statusText); + xhr.send(formData); + }); + } + const formData = new FormData(); + formData.append('file', files[0]); + const url = `${ghostPaths().apiRoot}/images/upload/`; + const response = await uploadToUrl(formData, url); + const dataset = JSON.parse(response); + const imageUrl = dataset?.images?.[0].url; + return { + src: imageUrl + }; + } return (
Loading editor...

}> - +
diff --git a/ghost/admin/app/components/member-attribution/source-attribution-table.js b/ghost/admin/app/components/member-attribution/source-attribution-table.js index 71bf61d2db3..1045d828828 100644 --- a/ghost/admin/app/components/member-attribution/source-attribution-table.js +++ b/ghost/admin/app/components/member-attribution/source-attribution-table.js @@ -36,7 +36,7 @@ export default class SourceAttributionTable extends Component { return null; } - if (this.sortedSources.length === 5 && !this.unavailableSource.length) { + if (this.sortedSources.length === 5 && !this.unavailableSource?.length) { return null; } diff --git a/ghost/admin/app/components/member/activity-feed.hbs b/ghost/admin/app/components/member/activity-feed.hbs index 8c22e50d032..95d7d54747a 100644 --- a/ghost/admin/app/components/member/activity-feed.hbs +++ b/ghost/admin/app/components/member/activity-feed.hbs @@ -6,7 +6,7 @@
{{else}} - {{#let (members-event-fetcher filter=(members-event-filter member=@member.id) pageSize=5) as |eventsFetcher|}} + {{#let (members-event-fetcher filter=(members-event-filter member=@member.id excludedEvents=this.excludedEventTypes) pageSize=5) as |eventsFetcher|}}
diff --git a/ghost/admin/app/components/member/activity-feed.js b/ghost/admin/app/components/member/activity-feed.js index d9f5654a5a2..ec9e34e1483 100644 --- a/ghost/admin/app/components/member/activity-feed.js +++ b/ghost/admin/app/components/member/activity-feed.js @@ -3,6 +3,7 @@ import {action} from '@ember/object'; export default class ActivityFeed extends Component { linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions + excludedEventTypes = ['email_sent_event']; @action enterLinkURL(event) { @@ -29,4 +30,4 @@ export default class ActivityFeed extends Component { child.style.transform = 'translateX(0)'; parent.classList.remove('scroller'); } -} \ No newline at end of file +} diff --git a/ghost/admin/app/components/members-activity/event-type-filter.js b/ghost/admin/app/components/members-activity/event-type-filter.js index 3ef84165539..920e53dc8e3 100644 --- a/ghost/admin/app/components/members-activity/event-type-filter.js +++ b/ghost/admin/app/components/members-activity/event-type-filter.js @@ -22,6 +22,12 @@ export default class MembersActivityEventTypeFilter extends Component { if (this.settings.commentsEnabled !== 'off') { extended.push({event: 'comment_event', icon: 'event-comment', name: 'Comments'}); } + if (this.settings.emailTrackClicks) { + extended.push({event: 'click_event', icon: 'event-click', name: 'Clicked link'}); + } + if (this.feature.audienceFeedback) { + extended.push({event: 'feedback_event', icon: 'event-more-like-this', name: 'Feedback'}); + } if (this.args.hiddenEvents?.length) { return extended.filter(t => !this.args.hiddenEvents.includes(t.event)); diff --git a/ghost/admin/app/components/members/filter-value.hbs b/ghost/admin/app/components/members/filter-value.hbs index 8db00eff3be..fcf85471aa8 100644 --- a/ghost/admin/app/components/members/filter-value.hbs +++ b/ghost/admin/app/components/members/filter-value.hbs @@ -47,47 +47,15 @@
-{{else if (eq @filter.type 'subscribed')}} +{{else if (eq @filter.valueType 'options')}} - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'last_seen_at')}} - - -{{else if (eq @filter.type 'created_at')}} - - -{{else if (eq @filter.type 'status')}} - -
- -{{else if (eq @filter.type 'subscriptions.plan_interval')}} - - - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'subscriptions.status')}} - - - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'subscriptions.start_date')}} +{{else if (or (eq @filter.type 'last_seen_at') (eq @filter.type 'created_at'))}} -{{else if (eq @filter.type 'subscriptions.current_period_end')}} +{{else if (eq @filter.valueType 'date')}} { - return { - id: resource - }; - }); + const resource = this.args.filter?.resource || undefined; + const resourceId = this.args.filter?.value || undefined; + return resource ?? { + id: resourceId + }; } @action - setResourceFilterValue(filter, resources) { - this.args.setFilterValue(filter, resources.map(resource => resource.id)); + setResourceFilterValue(filter, resource) { + this.args.setResourceValue(filter, resource); } } diff --git a/ghost/admin/app/components/members/filter.hbs b/ghost/admin/app/components/members/filter.hbs index fc935acefdd..f4bd1ae9c02 100644 --- a/ghost/admin/app/components/members/filter.hbs +++ b/ghost/admin/app/components/members/filter.hbs @@ -50,6 +50,7 @@ @index={{index}} @filter={{filter}} @setFilterValue={{this.setFilterValue}} + @setResourceValue={{this.setResourceValue}} @onLabelEdit={{@onLabelEdit}} /> diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js index 3223d3b4ff5..22536e9ade4 100644 --- a/ghost/admin/app/components/members/filter.js +++ b/ghost/admin/app/components/members/filter.js @@ -7,41 +7,9 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; -const FILTER_PROPERTIES = [ - // Basic - {label: 'Name', name: 'name', group: 'Basic', valueType: 'text'}, - {label: 'Email', name: 'email', group: 'Basic', valueType: 'text'}, - // {label: 'Location', name: 'location', group: 'Basic'}, - {label: 'Label', name: 'label', group: 'Basic', valueType: 'array'}, - {label: 'Newsletter subscription', name: 'subscribed', group: 'Basic'}, - {label: 'Last seen', name: 'last_seen_at', group: 'Basic', valueType: 'date'}, - {label: 'Created', name: 'created_at', group: 'Basic', valueType: 'date'}, - {label: 'Signed up on post/page', name: 'signup', group: 'Basic', valueType: 'array', feature: 'memberAttribution'}, - - // Member subscription - {label: 'Membership tier', name: 'tier', group: 'Subscription', valueType: 'array'}, - {label: 'Member status', name: 'status', group: 'Subscription'}, - {label: 'Billing period', name: 'subscriptions.plan_interval', group: 'Subscription'}, - {label: 'Stripe subscription status', name: 'subscriptions.status', group: 'Subscription'}, - {label: 'Paid start date', name: 'subscriptions.start_date', valueType: 'date', group: 'Subscription'}, - {label: 'Next billing date', name: 'subscriptions.current_period_end', valueType: 'date', group: 'Subscription'}, - {label: 'Subscription started on post/page', name: 'conversion', group: 'Subscription', valueType: 'array', feature: 'memberAttribution'}, - - // Emails - {label: 'Emails sent (all time)', name: 'email_count', group: 'Email'}, - {label: 'Emails opened (all time)', name: 'email_opened_count', group: 'Email'}, - {label: 'Open rate (all time)', name: 'email_open_rate', group: 'Email'}, - {label: 'Received email', name: 'emails.post_id', group: 'Email', valueType: 'array'}, - {label: 'Opened email', name: 'opened_emails.post_id', group: 'Email', valueType: 'array'}, - {label: 'Clicked email', name: 'clicked_links.post_id', group: 'Email', valueType: 'array'} - - // {label: 'Emails sent (30 days)', name: 'x', group: 'Email'}, - // {label: 'Emails opened (30 days)', name: 'x', group: 'Email'}, - // {label: 'Open rate (30 days)', name: 'x', group: 'Email'}, - // {label: 'Emails sent (60 days)', name: 'x', group: 'Email'}, - // {label: 'Emails opened (60 days)', name: 'x', group: 'Email'}, - // {label: 'Open rate (60 days)', name: 'x', group: 'Email'}, -]; +function escapeNqlString(value) { + return '\'' + value.replace(/'/g, '\\\'') + '\''; +} const MATCH_RELATION_OPTIONS = [ {label: 'is', name: 'is'}, @@ -56,13 +24,14 @@ const CONTAINS_RELATION_OPTIONS = [ {label: 'ends with', name: 'ends-with'} ]; +const FEEDBACK_RELATION_OPTIONS = [ + {label: 'More like this', name: 1}, + {label: 'Less like this', name: 0} +]; + const DATE_RELATION_OPTIONS = [ {label: 'before', name: 'is-less'}, {label: 'on or before', name: 'is-or-less'}, - // TODO: these cause problems because they require multiple NQL statements, eg: - // created_at:>='2022-03-02 00:00'+created_at:<'2022-03-03 00:00' - // {label: 'on', name: 'is'}, - // {label: 'not on', name: 'is-not'}, {label: 'after', name: 'is-greater'}, {label: 'on or after', name: 'is-or-greater'} ]; @@ -73,76 +42,369 @@ const NUMBER_RELATION_OPTIONS = [ {label: 'is less than', name: 'is-less'} ]; -const FILTER_RELATIONS_OPTIONS = { - name: CONTAINS_RELATION_OPTIONS, - email: CONTAINS_RELATION_OPTIONS, - label: MATCH_RELATION_OPTIONS, - tier: MATCH_RELATION_OPTIONS, - subscribed: MATCH_RELATION_OPTIONS, - last_seen_at: DATE_RELATION_OPTIONS, - created_at: DATE_RELATION_OPTIONS, - status: MATCH_RELATION_OPTIONS, - 'subscriptions.plan_interval': MATCH_RELATION_OPTIONS, - 'subscriptions.status': MATCH_RELATION_OPTIONS, - 'subscriptions.start_date': DATE_RELATION_OPTIONS, - 'subscriptions.current_period_end': DATE_RELATION_OPTIONS, - email_count: NUMBER_RELATION_OPTIONS, - email_opened_count: NUMBER_RELATION_OPTIONS, - email_open_rate: NUMBER_RELATION_OPTIONS, - signup: MATCH_RELATION_OPTIONS, - conversion: MATCH_RELATION_OPTIONS, - 'emails.post_id': MATCH_RELATION_OPTIONS, - 'clicked_links.post_id': MATCH_RELATION_OPTIONS, - 'opened_emails.post_id': MATCH_RELATION_OPTIONS +// Ideally we should move all the filter definitions to separate files +const NAME_FILTER = { + label: 'Name', + name: 'name', + group: 'Basic', + valueType: 'string', + relationOptions: CONTAINS_RELATION_OPTIONS }; -const FILTER_VALUE_OPTIONS = { - 'subscriptions.plan_interval': [ - {label: 'Monthly', name: 'month'}, - {label: 'Yearly', name: 'year'} - ], - status: [ - {label: 'Paid', name: 'paid'}, - {label: 'Free', name: 'free'}, - {label: 'Complimentary', name: 'comped'} - ], - subscribed: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} - ], - 'subscriptions.status': [ - {label: 'Active', name: 'active'}, - {label: 'Trialing', name: 'trialing'}, - {label: 'Canceled', name: 'canceled'}, - {label: 'Unpaid', name: 'unpaid'}, - {label: 'Past Due', name: 'past_due'}, - {label: 'Incomplete', name: 'incomplete'}, - {label: 'Incomplete - Expired', name: 'incomplete_expired'} - ] -}; +const FILTER_PROPERTIES = [ + // Basic + NAME_FILTER, + { + label: 'Email', + name: 'email', + group: 'Basic', + valueType: 'string', + relationOptions: CONTAINS_RELATION_OPTIONS + }, + { + label: 'Label', + name: 'label', + group: 'Basic', + valueType: 'array', + columnLabel: 'Label', + relationOptions: MATCH_RELATION_OPTIONS + }, + { + label: 'Newsletter subscription', + name: 'subscribed', + group: 'Basic', + columnLabel: 'Subscribed', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Subscribed', name: 'true'}, + {label: 'Unsubscribed', name: 'false'} + ] + }, + { + label: 'Last seen', + name: 'last_seen_at', + group: 'Basic', + valueType: 'date', + columnLabel: 'Last seen at', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Created', + name: 'created_at', + group: 'Basic', + valueType: 'date', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Signed up on post/page', + name: 'signup', + group: 'Basic', + valueType: 'string', + resource: 'post', + feature: 'memberAttribution', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Signed up on', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + + // Member subscription + { + label: 'Membership tier', + name: 'tier', + group: 'Subscription', + valueType: 'array', + columnLabel: 'Membership tier', + relationOptions: MATCH_RELATION_OPTIONS + }, + { + label: 'Member status', + name: 'status', + group: 'Subscription', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Paid', name: 'paid'}, + {label: 'Free', name: 'free'}, + {label: 'Complimentary', name: 'comped'} + ] + }, + { + label: 'Billing period', + name: 'subscriptions.plan_interval', + group: 'Subscription', + columnLabel: 'Billing period', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Monthly', name: 'month'}, + {label: 'Yearly', name: 'year'} + ] + }, + { + label: 'Stripe subscription status', + name: 'subscriptions.status', + group: 'Subscription', + columnLabel: 'Subscription Status', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + options: [ + {label: 'Active', name: 'active'}, + {label: 'Trialing', name: 'trialing'}, + {label: 'Canceled', name: 'canceled'}, + {label: 'Unpaid', name: 'unpaid'}, + {label: 'Past Due', name: 'past_due'}, + {label: 'Incomplete', name: 'incomplete'}, + {label: 'Incomplete - Expired', name: 'incomplete_expired'} + ] + }, + { + label: 'Paid start date', + name: 'subscriptions.start_date', + valueType: 'date', + group: 'Subscription', + columnLabel: 'Paid start date', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Next billing date', + name: 'subscriptions.current_period_end', + valueType: 'date', + group: 'Subscription', + columnLabel: 'Next billing date', + relationOptions: DATE_RELATION_OPTIONS + }, + { + label: 'Subscription started on post/page', + name: 'conversion', + group: 'Subscription', + valueType: 'string', + resource: 'post', + feature: 'memberAttribution', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Subscription started on', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + + // Emails + { + label: 'Emails sent (all time)', + name: 'email_count', + group: 'Email', + columnLabel: 'Email count', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS + }, + { + label: 'Emails opened (all time)', + name: 'email_opened_count', + group: 'Email', + columnLabel: 'Email opened count', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS + }, + { + label: 'Open rate (all time)', + name: 'email_open_rate', + group: 'Email', + valueType: 'number', + relationOptions: NUMBER_RELATION_OPTIONS + }, + { + label: 'Received email', + name: 'emails.post_id', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Received email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + { + label: 'Opened email', + name: 'opened_emails.post_id', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Opened email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + { + label: 'Clicked email', + name: 'clicked_links.post_id', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: MATCH_RELATION_OPTIONS, + getColumns: filter => [ + { + label: 'Clicked email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + } + ] + }, + { + label: 'Responded with feedback', + name: 'newsletter_feedback', + group: 'Email', + valueType: 'string', + resource: 'email', + relationOptions: FEEDBACK_RELATION_OPTIONS, + feature: 'audienceFeedback', + buildNqlFilter: (filter) => { + // Added brackets to make sure we can parse as a single AND filter + return `(feedback.post_id:${filter.value}+feedback.score:${filter.relation})`; + }, + parseNqlFilter: (filter) => { + if (!filter.$and) { + return; + } + if (filter.$and.length === 2) { + if (filter.$and[0]['feedback.post_id'] && filter.$and[1]['feedback.score'] !== undefined) { + return { + relation: parseInt(filter.$and[1]['feedback.score']), + value: filter.$and[0]['feedback.post_id'] + }; + } + } + }, + getColumns: filter => [ + { + label: 'Email', + getValue: () => { + return { + class: '', + text: filter.resource?.title ?? '' + }; + } + }, + { + label: 'Feedback', + getValue: () => { + return { + class: 'gh-members-list-feedback', + text: filter.relation === 1 ? 'More like this' : 'Less like this', + icon: filter.relation === 1 ? 'event-more-like-this' : 'event-less-like-this' + }; + } + } + ] + } +]; class Filter { - @tracked type; @tracked value; @tracked relation; - @tracked relationOptions; + @tracked properties; + @tracked resource; constructor(options) { - this.type = options.type; - this.relation = options.relation; - this.relationOptions = options.relationOptions; - this.timezone = options.timezone || 'Etc/UTC'; + this.properties = options.properties; + this.timezone = options.timezone ?? 'Etc/UTC'; - const filterProperty = FILTER_PROPERTIES.find(prop => this.type === prop.name); + let defaultRelation = options.properties.relationOptions[0].name; + if (options.properties.valueType === 'date') { + defaultRelation = 'is-or-less'; + } + + let defaultValue = ''; + if (options.properties.valueType === 'options' && options.properties.options.length > 0) { + defaultValue = options.properties.options[0].name; + } else if (options.properties.valueType === 'array') { + defaultValue = []; + } else if (options.properties.valueType === 'date') { + defaultValue = moment(moment.tz(this.timezone).format('YYYY-MM-DD')).toDate(); + } + + this.relation = options.relation ?? defaultRelation; // date string values are passed in as UTC strings // we need to convert them to the site timezone and make a local date that matches // so the date string output in the filter inputs is correct - const value = filterProperty.valueType === 'date' && typeof options.value === 'string' - ? moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate() - : options.value; + this.value = options.value ?? defaultValue; + + if (this.properties.valueType === 'date' && typeof this.value === 'string') { + // Convert string to Date + this.value = moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate(); + } + + // Validate value + if (options.properties.valueType === 'options') { + if (!options.properties.options.find(option => option.name === this.value)) { + this.value = defaultValue; + } + } + + this.resource = null; + } - this.value = value; + get valueType() { + return this.properties.valueType; + } + + get type() { + return this.properties.name; + } + + get isResourceFilter() { + return typeof this.properties.resource === 'string' && this.properties.valueType === 'string'; + } + + get relationOptions() { + return this.properties.relationOptions; + } + + get options() { + return this.properties.options ?? []; + } + + get isValid() { + if (Array.isArray(this.value)) { + return !!this.value.length; + } + return !!this.value; } } @@ -154,16 +416,10 @@ export default class MembersFilter extends Component { @tracked filters = new TrackedArray([ new Filter({ - type: 'name', - relation: 'is', - value: '', - relationOptions: FILTER_RELATIONS_OPTIONS.name + properties: NAME_FILTER }) ]); - availableFilterRelationsOptions = FILTER_RELATIONS_OPTIONS; - availableFilterValueOptions = FILTER_VALUE_OPTIONS; - get availableFilterProperties() { let availableFilters = FILTER_PROPERTIES; const hasMultipleTiers = this.store.peekAll('tier').length > 1; @@ -212,21 +468,24 @@ export default class MembersFilter extends Component { @action parseDefaultFilters() { if (this.args.defaultFilterParam) { - this.parseNqlFilter(this.args.defaultFilterParam); + // check if it is different before parsing + const validFilters = this.validFilters; + const currentFilter = this.generateNqlFilter(validFilters); - // Pass the parsed filter to the parent component - // this doesn't start a new network request, and doesn't update filterParam again - this.applyParsedFilter(); + if (currentFilter !== this.args.defaultFilterParam) { + this.parseNqlFilterString(this.args.defaultFilterParam); + + // Pass the parsed filter to the parent component + // this doesn't start a new network request, and doesn't update filterParam again + this.applyParsedFilter(); + } } } @action addFilter() { this.filters.push(new Filter({ - type: 'name', - relation: 'is', - value: '', - relationOptions: FILTER_RELATIONS_OPTIONS.name + properties: NAME_FILTER })); this.applySoftFilter(); } @@ -241,14 +500,19 @@ export default class MembersFilter extends Component { let query = ''; filters.forEach((filter) => { - const relationStr = this.getFilterRelationOperator(filter.relation); const filterProperty = FILTER_PROPERTIES.find(prop => prop.name === filter.type); + if (filterProperty.buildNqlFilter) { + query += `${filterProperty.buildNqlFilter(filter)}+`; + return; + } + const relationStr = this.getFilterRelationOperator(filter.relation); + if (filterProperty.valueType === 'array' && filter.value?.length) { const filterValue = '[' + filter.value.join(',') + ']'; query += `${filter.type}:${relationStr}${filterValue}+`; - } else if (filterProperty.valueType === 'text') { - const filterValue = '\'' + filter.value.replace(/'/g, '\\\'') + '\''; + } else if (filterProperty.valueType === 'string') { + let filterValue = escapeNqlString(filter.value); query += `${filter.type}:${relationStr}${filterValue}+`; } else if (filterProperty.valueType === 'date') { let filterValue; @@ -278,6 +542,74 @@ export default class MembersFilter extends Component { return query.slice(0, -1); } + parseNqlFilterString(filterParam) { + let filters; + + try { + filters = nql.parse(filterParam); + } catch (e) { + // Invalid nql filter + this.filters = new TrackedArray([]); + return; + } + this.filters = new TrackedArray(this.parseNqlFilter(filters)); + } + + parseNqlFilter(filter) { + const parsedFilters = []; + + // Check custom parsing + for (const filterProperties of FILTER_PROPERTIES) { + if (filterProperties.parseNqlFilter) { + // This filter has a custom parsing function + const parsedFilter = filterProperties.parseNqlFilter(filter); + if (parsedFilter) { + parsedFilters.push(new Filter({ + properties: filterProperties, + timezone: this.settings.timezone, + ...parsedFilter + })); + return parsedFilters; + } + } + } + + if (filter.$and) { + parsedFilters.push(...this.parseNqlFilters(filter.$and)); + } else if (filter.yg) { + // Single filter grouped in backets + parsedFilters.push(...this.parseNqlFilter(filter.yg)); + } else { + const filterKeys = Object.keys(filter); + const validKeys = FILTER_PROPERTIES.map(prop => prop.name); + + for (const key of filterKeys) { + if (validKeys.includes(key)) { + const parsedFilter = this.parseNqlFilterKey({ + [key]: filter[key] + }); + if (parsedFilter) { + parsedFilters.push(parsedFilter); + } + } + } + } + return parsedFilters; + } + + /** + * Parses an array of filters + */ + parseNqlFilters(filters) { + const parsedFilters = []; + + for (const filter of filters) { + parsedFilters.push(...this.parseNqlFilter(filter)); + } + + return parsedFilters; + } + parseNqlFilterKey(nqlFilter) { const keys = Object.keys(nqlFilter); const key = keys[0]; @@ -359,53 +691,16 @@ export default class MembersFilter extends Component { } if (relation && value) { - return new Filter({ - type: key, - relation, - relationOptions: FILTER_RELATIONS_OPTIONS[key], - value, - timezone: this.settings.timezone - }); - } - } - - parseNqlFilter(filterParam) { - const validKeys = Object.keys(FILTER_RELATIONS_OPTIONS); - let filters; - - try { - filters = nql.parse(filterParam); - } catch (e) { - // Invalid nql filter - this.filters = new TrackedArray([]); - return; - } - - const filterKeys = Object.keys(filters); - - let filterData = []; - - if (filterKeys?.length === 1 && validKeys.includes(filterKeys[0])) { - const filterObj = this.parseNqlFilterKey(filters); - if (filterObj) { - filterData = [filterObj]; + const properties = FILTER_PROPERTIES.find(prop => key === prop.name); + if (FILTER_PROPERTIES.find(prop => key === prop.name)) { + return new Filter({ + properties, + relation, + value, + timezone: this.settings.timezone + }); } - } else if (filters?.$and) { - const andFilters = filters?.$and || []; - filterData = andFilters.filter((nqlFilter) => { - const _filterKeys = Object.keys(nqlFilter); - if (_filterKeys?.length === 1 && validKeys.includes(_filterKeys[0])) { - return true; - } - return false; - }).map((nqlFilter) => { - return this.parseNqlFilterKey(nqlFilter); - }).filter((nqlFilter) => { - return !!nqlFilter; - }); } - - this.filters = new TrackedArray(filterData); } getFilterRelationOperator(relation) { @@ -456,40 +751,21 @@ export default class MembersFilter extends Component { const newProp = FILTER_PROPERTIES.find(prop => prop.name === newType); - let defaultValue = this.availableFilterValueOptions[newType] - ? this.availableFilterValueOptions[newType][0].name - : ''; - - if (newProp.valueType === 'array' && !defaultValue) { - defaultValue = []; - } - - if (newProp.valueType === 'date' && !defaultValue) { - defaultValue = moment(moment.tz(this.settings.timezone).format('YYYY-MM-DD')).toDate(); - } - - let defaultRelation = this.availableFilterRelationsOptions[newType][0].name; - - if (newProp.valueType === 'date') { - defaultRelation = 'is-or-less'; + if (!newProp) { + // eslint-disable-next-line no-console + console.warn('Invalid Filter Type Selected', newType); + return; } const newFilter = new Filter({ - type: newType, - relation: defaultRelation, - relationOptions: this.availableFilterRelationsOptions[newType], - value: defaultValue, + properties: newProp, timezone: this.settings.timezone }); const filterToSwap = this.filters.find(f => f === filter); this.filters[this.filters.indexOf(filterToSwap)] = newFilter; - if (newType !== 'label' && defaultValue) { - this.applySoftFilter(); - } - - if (newType !== 'tier' && defaultValue) { + if (newFilter.isValid) { this.applySoftFilter(); } } @@ -503,44 +779,48 @@ export default class MembersFilter extends Component { @action setFilterValue(filter, newValue) { filter.value = newValue; + filter.resource = null; this.applySoftFilter(); } + @action + setResourceValue(filter, resource) { + filter.value = resource.id; + filter.resource = resource; + this.applySoftFilter(); + } + + get validFilters() { + return this.filters.filter(filter => filter.isValid); + } + @action applySoftFilter() { - const validFilters = this.filters.filter((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.length; - } - return filter.value; - }); + const validFilters = this.validFilters; const query = this.generateNqlFilter(validFilters); this.args.onApplySoftFilter(query, validFilters); + this.fetchFilterResourcesTask.perform(); } @action applyFilter() { - const validFilters = this.filters.filter((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.length; - } - return filter.value; - }); - + const validFilters = this.validFilters; const query = this.generateNqlFilter(validFilters); this.args.onApplyFilter(query, validFilters); + this.fetchFilterResourcesTask.perform(); + } + + @action + applyFiltersPressed(dropdown) { + dropdown?.actions.close(); + this.applyFilter(); } @action applyParsedFilter() { - const validFilters = this.filters.filter((filter) => { - if (Array.isArray(filter.value)) { - return filter.value.length; - } - return filter.value; - }); - + const validFilters = this.validFilters; this.args.onApplyParsedFilter(validFilters); + this.fetchFilterResourcesTask.perform(); } @action @@ -548,10 +828,7 @@ export default class MembersFilter extends Component { const filters = []; filters.push(new Filter({ - type: 'name', - relation: 'is', - value: '', - relationOptions: FILTER_RELATIONS_OPTIONS.name + properties: NAME_FILTER })); this.filters = new TrackedArray(filters); @@ -563,4 +840,32 @@ export default class MembersFilter extends Component { const response = yield this.store.query('tier', {filter: 'type:paid'}); this.tiersList = response; } + + @task({restartable: true}) + *fetchFilterResourcesTask() { + const ids = []; + for (const filter of this.filters) { + if (filter.isResourceFilter) { + // for now we only support post filters + if (filter.value && !ids.includes(filter.value)) { + ids.push(filter.value); + } + } + } + if (ids.length > 0) { + const posts = yield this.store.query('post', {limit: 'all', filter: `id:[${ids.join(',')}]`}); + + for (const filter of this.filters) { + if (filter.isResourceFilter) { + // for now we only support post filters + if (filter.value) { + const post = posts.find(p => p.id === filter.value); + if (post) { + filter.resource = post; + } + } + } + } + } + } } diff --git a/ghost/admin/app/components/members/list-item-column.hbs b/ghost/admin/app/components/members/list-item-column.hbs index 8045c8d72d0..9fe5c609850 100644 --- a/ghost/admin/app/components/members/list-item-column.hbs +++ b/ghost/admin/app/components/members/list-item-column.hbs @@ -1,24 +1,15 @@ -{{#if (eq @filterColumn 'label')}} - +{{#if (eq this.columnName 'label')}} + {{this.labels}} -{{else if (eq @filterColumn 'tier')}} - +{{else if (eq this.columnName 'tier')}} + {{this.tiers}} -{{else if (eq @filterColumn 'status')}} - - {{#if (not (is-empty @member.status))}} - {{capitalize @member.status}} - {{else}} - - - {{/if}} - - -{{else if (eq @filterColumn 'last_seen_at')}} - +{{else if (eq this.columnName 'last_seen_at')}} + {{#if (not (is-empty @member.lastSeenAtUTC))}} {{moment-format (moment-site-tz @member.lastSeenAtUTC) "DD MMM YYYY"}}
{{moment-from-now @member.lastSeenAtUTC}}
@@ -27,8 +18,8 @@ {{/if}}
-{{else if (eq @filterColumn 'email_count')}} - +{{else if (eq this.columnName 'email_count')}} + {{#if (not (is-empty @member.emailCount))}} {{@member.emailCount}} {{else}} @@ -36,8 +27,8 @@ {{/if}} -{{else if (eq @filterColumn 'email_opened_count')}} - +{{else if (eq this.columnName 'email_opened_count')}} + {{#if (not (is-empty @member.emailOpenedCount))}} {{@member.emailOpenedCount}} {{else}} @@ -45,8 +36,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscribed')}} - +{{else if (eq this.columnName 'subscribed')}} + {{#if (not (is-empty @member.subscribed))}} {{if @member.subscribed "Yes" "No"}} {{else}} @@ -54,8 +45,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscriptions.status')}} - +{{else if (eq this.columnName 'subscriptions.status')}} + {{#if (not (is-empty this.mostRecentSubscription.status))}} {{capitalize this.mostRecentSubscription.status}} {{else}} @@ -63,8 +54,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscriptions.plan_interval')}} - +{{else if (eq this.columnName 'subscriptions.plan_interval')}} + {{#if (not (is-empty this.mostRecentSubscription.price.interval))}} {{capitalize this.mostRecentSubscription.price.interval}} {{else}} @@ -72,8 +63,8 @@ {{/if}} -{{else if (eq @filterColumn 'subscriptions.start_date')}} - +{{else if (eq this.columnName 'subscriptions.start_date')}} + {{#if (not (is-empty this.mostRecentSubscription.start_date))}} {{moment-format (moment-site-tz this.mostRecentSubscription.start_date) "DD MMM YYYY"}}
{{moment-from-now this.mostRecentSubscription.start_date}}
@@ -82,8 +73,8 @@ {{/if}}
-{{else if (eq @filterColumn 'subscriptions.current_period_end')}} - +{{else if (eq this.columnName 'subscriptions.current_period_end')}} + {{#if (not (is-empty this.mostRecentSubscription.current_period_end))}} {{moment-format (moment-site-tz this.mostRecentSubscription.current_period_end) "DD MMM YYYY"}}
{{moment-from-now this.mostRecentSubscription.current_period_end}}
@@ -91,4 +82,17 @@ - {{/if}}
+{{else}} + + {{#if this.columnValue}} +
+ {{#if this.columnValue.icon}} + {{svg-jar this.columnValue.icon}} + {{/if}} + {{this.columnValue.text}} +
+ {{else}} + - + {{/if}} +
{{/if}} diff --git a/ghost/admin/app/components/members/list-item-column.js b/ghost/admin/app/components/members/list-item-column.js index 3f19c6410c8..3537ca55f29 100644 --- a/ghost/admin/app/components/members/list-item-column.js +++ b/ghost/admin/app/components/members/list-item-column.js @@ -20,4 +20,12 @@ export default class MembersListItemColumn extends Component { get mostRecentSubscription() { return mostRecentlyUpdated(get(this.args.member, 'subscriptions')); } + + get columnName() { + return this.args.filterColumn.name; + } + + get columnValue() { + return this.args.filterColumn?.getValue ? this.args.filterColumn?.getValue(this.args.member) : null; + } } diff --git a/ghost/admin/app/components/modals/newsletters/edit/settings.hbs b/ghost/admin/app/components/modals/newsletters/edit/settings.hbs index 4efcf9d12bf..72a0df9f6eb 100644 --- a/ghost/admin/app/components/modals/newsletters/edit/settings.hbs +++ b/ghost/admin/app/components/modals/newsletters/edit/settings.hbs @@ -114,22 +114,10 @@
- - - {{/liquid-if}} - {{/let}} - {{#if (feature "audienceFeedback")}} - {{#let (eq @openSection "audienceFeedback") as |isOpen|}} - - {{#liquid-if isOpen}} - + + {{/liquid-if}} + {{/let}} + diff --git a/ghost/admin/app/components/posts/analytics.hbs b/ghost/admin/app/components/posts/analytics.hbs index 4c74febbdaf..e07d441674d 100644 --- a/ghost/admin/app/components/posts/analytics.hbs +++ b/ghost/admin/app/components/posts/analytics.hbs @@ -1,5 +1,4 @@ -
- +
@@ -42,103 +41,127 @@
-

- Engagement -

-
+ {{#if this.post.hasBeenEmailed}} -
- -

{{format-number this.post.email.emailCount}}

-

Sent

-
-
+ +

Sent

+

{{format-number this.post.email.emailCount}}

+
+ + + + {{#if this.post.showEmailOpenAnalytics }} -
- -

{{format-number this.post.email.openedCount}}

-

Opened — {{this.post.email.openRate}}%

-
-
+ +

Opened

+

{{format-number this.post.email.openedCount}} {{this.post.email.openRate}}%

+
+ + + + {{/if}} {{#if this.post.showEmailClickAnalytics }} -
- -

{{format-number this.post.count.clicks}}

-

Clicked — {{this.post.clickRate}}%

-
-
+ +

Clicked

+

{{format-number this.post.count.clicks}} {{this.post.clickRate}}%

+
+ + + + {{/if}} {{/if}} - {{#if this.post.showAttributionAnalytics }} -
- -

{{format-number this.post.count.signups}}

-

{{gh-pluralize this.post.count.signups "signup" without-count=true}}

-
-
+ {{#if this.post.showAudienceFeedback }} + +

Positive feedback

+

+ {{format-number this.post.count.positive_feedback}} + {{!-- {{this.post.sentiment}}% --}} +

+
- {{#if this.post.showPaidAttributionAnalytics }} -
- -

{{format-number this.post.count.paid_conversions}}

-

Paid {{gh-pluralize this.post.count.paid_conversions "conversion" without-count=true}}

-
-
- {{/if}} + + + {{/if}} - {{#if this.post.showAudienceFeedback }} -
-

{{format-number this.post.count.positive_feedback}}

-

More like this — {{this.post.count.sentiment}}%

-
+ {{#if this.post.showAttributionAnalytics }} + +

{{gh-pluralize this.post.count.conversions "Conversions" without-count=true}}

+

{{format-number this.post.count.conversions}}

+
+ + + + {{/if}} -
+ {{#if this.isLoaded }} - {{#if this.showLinks }} - {{#if (is-empty this.links) }} - {{!-- Empty state --}} - {{else}} - +
+ {{#if this.showSources }} +
+

Post growth

+ {{#if (is-empty this.sources) }} +
+ {{svg-jar "members-outline"}} +

No new members for this post

+

Once someone signs up, you'll be able to see where they came from here.

+
+ {{else}} + + {{/if}} +
{{/if}} - {{/if}} - {{#if this.showSources }} - {{#if (is-empty this.sources) }} + {{#if this.showLinks }} + {{#if (is-empty this.links) }} {{!-- Empty state --}} - {{else}} -

- Growth from this post -

-
-
-
- -
-
-
+ {{else}} + + {{/if}} {{/if}} - {{/if}} +
-

- Get started with analytics -

-

Understanding analytics in Ghost

-

Find out how to review the performance of your content and get the most out of post analytics in Ghost.

+

+ Ghost help +

+
+

Understanding analytics in Ghost

+

Find out how to review the performance of your content and get the most out of post analytics in Ghost.

+
@@ -147,8 +170,13 @@
-

How to get your content seen online

-

Use these content distribution tactics to get more people to discover your work and increase engagement.

+

+ Ghost resources +

+
+

How to get your content seen online

+

Use these content distribution tactics to get more people to discover your work and increase engagement.

+
diff --git a/ghost/admin/app/components/posts/analytics.js b/ghost/admin/app/components/posts/analytics.js index e751179533b..e784d9b15b0 100644 --- a/ghost/admin/app/components/posts/analytics.js +++ b/ghost/admin/app/components/posts/analytics.js @@ -94,6 +94,23 @@ export default class Analytics extends Component { this.sortColumn = column; } + @action + updateLink(linkId, linkTo) { + this.links = this.links?.map((link) => { + if (link.link.link_id === linkId) { + return { + ...link, + link: { + ...link.link, + to: this.utils.cleanTrackedUrl(linkTo, false), + title: this.utils.cleanTrackedUrl(linkTo, true) + } + }; + } + return link; + }); + } + @action loadData() { if (this.showSources) { diff --git a/ghost/admin/app/components/posts/feedback-events-chart.hbs b/ghost/admin/app/components/posts/feedback-events-chart.hbs new file mode 100644 index 00000000000..ac0c1ab645a --- /dev/null +++ b/ghost/admin/app/components/posts/feedback-events-chart.hbs @@ -0,0 +1,7 @@ + diff --git a/ghost/admin/app/components/posts/feedback-events-chart.js b/ghost/admin/app/components/posts/feedback-events-chart.js new file mode 100644 index 00000000000..25edcf924eb --- /dev/null +++ b/ghost/admin/app/components/posts/feedback-events-chart.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import {inject as service} from '@ember/service'; + +const CHART_COLORS = [ + '#F080B2', + '#8452f633' +]; + +const linksClass = ['gh-post-activity-chart-positive-feedback', 'gh-post-activity-chart-negative-feedback']; + +export default class FeedbackEventsChart extends Component { + @service feature; + + getSumOfData() { + return this.args.data.reduce((acc, value) => { + return acc + value; + }, 0); + } + + get chartOptions() { + return { + cutoutPercentage: 70, + title: { + display: false + }, + legend: { + display: false + }, + tooltips: { + enabled: false, + mode: 'label', + custom: function (tooltip) { + // get tooltip element + const tooltipEl = document.getElementById('gh-feedback-events-tooltip'); + + let offsetX = -50; + let offsetY = -100; + + // update tooltip styles + tooltipEl.style.opacity = 1; + tooltipEl.style.position = 'absolute'; + tooltipEl.style.left = tooltip.x + offsetX + 'px'; + tooltipEl.style.top = tooltip.y + offsetY + 'px'; + tooltipEl.style.pointerEvents = 'all'; + }, + callbacks: { + label: (tooltipItems, data) => { + const tooltipTextEl = document.getElementById('gh-feedback-events-tooltip-body'); + const label = data.labels[tooltipItems.index] || ''; + const value = data.datasets[tooltipItems.datasetIndex].data[tooltipItems.index] || 0; + const formattedValue = value.toLocaleString('en-US'); + const percent = Math.round(value / this.getSumOfData() * 100); + const links = document.querySelectorAll(`.gh-feedback-events-tooltip-template .gh-post-activity-chart-link`); + links.forEach((link) => { + link.setAttribute('hidden', 'true'); + }); + const linkNode = document.querySelector(`.${linksClass[tooltipItems.index]}`); + linkNode.setAttribute('hidden', 'false'); + + tooltipTextEl.innerHTML = (` +
+ + ${formattedValue} + ${percent}% +
+ + ${label} + `); + }, + title: () => { + return null; + } + } + }, + aspectRatio: 1 + }; + } + + get chartData() { + let borderColor = this.feature.nightShift ? '#101114' : '#fff'; + + return { + labels: ['More like this', 'Less like this'], + datasets: [{ + label: 'Feedback events', + data: this.args.data, + backgroundColor: CHART_COLORS, + borderWidth: 2, + borderColor: borderColor, + hoverBorderWidth: 2, + hoverBorderColor: borderColor + }] + }; + } +} diff --git a/ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs b/ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs new file mode 100644 index 00000000000..a23d3bda580 --- /dev/null +++ b/ghost/admin/app/components/posts/feedback-events-tooltip-template.hbs @@ -0,0 +1,19 @@ +
+ + {{svg-jar "filter"}} + See members + + + + {{svg-jar "filter"}} + See members + +
\ No newline at end of file diff --git a/ghost/admin/app/components/posts/links-table.hbs b/ghost/admin/app/components/posts/links-table.hbs index 8095e6966c3..1c74a5f4ec9 100644 --- a/ghost/admin/app/components/posts/links-table.hbs +++ b/ghost/admin/app/components/posts/links-table.hbs @@ -1,37 +1,157 @@ -

- Newsletter clicks -

-
+{{#if (not (feature "audienceFeedback"))}} +

+ Newsletter clicks +

+{{/if}} +
+ {{#if (feature "audienceFeedback")}}

Newsletter clicks

{{/if}}
{{/if}} diff --git a/ghost/admin/ember-cli-build.js b/ghost/admin/ember-cli-build.js index 36e2d291d47..58d904a7ac2 100644 --- a/ghost/admin/ember-cli-build.js +++ b/ghost/admin/ember-cli-build.js @@ -118,7 +118,7 @@ module.exports = function (defaults) { includePolyfill: false }, 'ember-composable-helpers': { - only: ['join', 'optional', 'pick', 'toggle', 'toggle-action'] + only: ['join', 'optional', 'pick', 'toggle', 'toggle-action', 'compute'] }, 'ember-promise-modals': { excludeCSS: true diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 96bb0198cad..65ee83c23a5 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.18.0", + "version": "5.20.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", @@ -31,7 +31,7 @@ "devDependencies": { "@babel/eslint-parser": "7.19.1", "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-decorators": "7.19.3", + "@babel/plugin-proposal-decorators": "7.19.6", "@ember/jquery": "2.0.0", "@ember/optional-features": "2.0.0", "@ember/render-modifiers": "2.0.4", @@ -44,8 +44,8 @@ "@tryghost/color-utils": "0.1.21", "@tryghost/ember-promise-modals": "2.0.1", "@tryghost/helpers": "1.1.74", - "@tryghost/kg-clean-basic-html": "2.2.20", - "@tryghost/kg-parser-plugins": "2.12.5", + "@tryghost/kg-clean-basic-html": "2.2.23", + "@tryghost/kg-parser-plugins": "2.12.8", "@tryghost/limit-service": "1.2.3", "@tryghost/members-csv": "0.0.0", "@tryghost/mobiledoc-kit": "0.12.5-ghost.2", @@ -69,7 +69,7 @@ "element-resize-detector": "1.2.4", "ember-ajax": "5.1.2", "ember-assign-helper": "0.4.0", - "ember-auto-import": "2.4.2", + "ember-auto-import": "2.4.3", "ember-classic-decorator": "3.0.1", "ember-cli": "3.24.0", "ember-cli-app-version": "5.0.0", @@ -113,12 +113,12 @@ "ember-simple-auth": "4.2.2", "ember-sinon": "5.0.0", "ember-source": "3.24.0", - "ember-svg-jar": "2.4.0", - "ember-template-lint": "4.15.0", + "ember-svg-jar": "2.4.2", + "ember-template-lint": "4.16.1", "ember-test-selectors": "6.0.0", "ember-tooltips": "3.6.0", "ember-truth-helpers": "3.1.1", - "eslint": "8.25.0", + "eslint": "8.26.0", "eslint-plugin-babel": "5.3.1", "eslint-plugin-react": "7.31.10", "faker": "5.5.3", @@ -152,7 +152,7 @@ "testem": "3.9.0", "top-gh-contribs": "2.0.4", "tracked-built-ins": "3.1.0", - "util": "0.12.4", + "util": "0.12.5", "validator": "7.2.0", "walk-sync": "3.0.0" }, @@ -181,4 +181,4 @@ "path-browserify": "1.0.1", "webpack": "5.74.0" } -} +} \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/empty-clicked.svg b/ghost/admin/public/assets/icons/empty-clicked.svg new file mode 100644 index 00000000000..00917ca16d2 --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-clicked.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/empty-conversion.svg b/ghost/admin/public/assets/icons/empty-conversion.svg new file mode 100644 index 00000000000..d6693e34374 --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-conversion.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/empty-feedback.svg b/ghost/admin/public/assets/icons/empty-feedback.svg new file mode 100644 index 00000000000..f68ed6ac0ce --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/empty-opened.svg b/ghost/admin/public/assets/icons/empty-opened.svg new file mode 100644 index 00000000000..76b86926a24 --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-opened.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/empty-sent.svg b/ghost/admin/public/assets/icons/empty-sent.svg new file mode 100644 index 00000000000..352a57e217a --- /dev/null +++ b/ghost/admin/public/assets/icons/empty-sent.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg b/ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg new file mode 100644 index 00000000000..81b7b896932 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-less-like-this--feature-attribution.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/event-less-like-this.svg b/ghost/admin/public/assets/icons/event-less-like-this.svg new file mode 100644 index 00000000000..539880fe3d7 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-less-like-this.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg b/ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg new file mode 100644 index 00000000000..ff9f1c8df86 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-more-like-this--feature-attribution.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/public/assets/icons/event-more-like-this.svg b/ghost/admin/public/assets/icons/event-more-like-this.svg new file mode 100644 index 00000000000..c9c8db2fc78 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-more-like-this.svg @@ -0,0 +1,3 @@ + + + diff --git a/ghost/admin/tests/acceptance/authentication-test.js b/ghost/admin/tests/acceptance/authentication-test.js index 36bf6910ab8..918f0a9ba02 100644 --- a/ghost/admin/tests/acceptance/authentication-test.js +++ b/ghost/admin/tests/acceptance/authentication-test.js @@ -1,8 +1,9 @@ +import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import windowProxy from 'ghost-admin/utils/window-proxy'; import {Response} from 'miragejs'; import {afterEach, beforeEach, describe, it} from 'mocha'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; -import {click, currentRouteName, currentURL, fillIn, findAll, visit} from '@ember/test-helpers'; +import {currentRouteName, currentURL, fillIn, findAll, triggerKeyEvent, visit} from '@ember/test-helpers'; import {expect} from 'chai'; import {run} from '@ember/runloop'; import {setupApplicationTest} from 'ember-mocha'; @@ -49,7 +50,7 @@ describe('Acceptance: Authentication', function () { it('invalidates session on 401 API response', async function () { // return a 401 when attempting to retrieve users - this.server.get('/users/', () => new Response(401, {}, { + this.server.get('/users/me', () => new Response(401, {}, { errors: [ {message: 'Access denied.', type: 'UnauthorizedError'} ] @@ -68,6 +69,27 @@ describe('Acceptance: Authentication', function () { expect(currentURL(), 'url after 401').to.equal('/signin'); }); + it('invalidates session on 403 API response', async function () { + // return a 401 when attempting to retrieve users + this.server.get('/users/me', () => new Response(403, {}, { + errors: [ + {message: 'Authorization failed', type: 'NoPermissionError'} + ] + })); + + await authenticateSession(); + await visit('/settings/staff'); + + // running `visit(url)` inside windowProxy.replaceLocation breaks + // the async behaviour so we need to run `visit` here to simulate + // the browser visiting the new page + if (newLocation) { + await visit(newLocation); + } + + expect(currentURL(), 'url after 403').to.equal('/signin'); + }); + it('doesn\'t show navigation menu on invalid url when not authenticated', async function () { await invalidateSession(); @@ -94,7 +116,7 @@ describe('Acceptance: Authentication', function () { }); // TODO: re-enable once modal reappears correctly - describe.skip('editor', function () { + describe('editor', function () { let origDebounce = run.debounce; let origThrottle = run.throttle; @@ -107,13 +129,14 @@ describe('Acceptance: Authentication', function () { it('displays re-auth modal attempting to save with invalid session', async function () { let role = this.server.create('role', {name: 'Administrator'}); this.server.create('user', {roles: [role]}); + let testOn = 'save'; // use marker for different type of server.put result // simulate an invalid session when saving the edited post - this.server.put('/posts/:id/', function ({posts}, {params}) { + this.server.put('/posts/:id/', function ({posts, db}, {params}) { let post = posts.find(params.id); - let attrs = this.normalizedRequestAttrs(); + let attrs = db.posts.find(params.id); // use attribute from db.posts to avoid hasInverseFor error - if (attrs.mobiledoc.cards[0][1].markdown === 'Edited post body') { + if (testOn === 'edit') { return new Response(401, {}, { errors: [ {message: 'Access denied.', type: 'UnauthorizedError'} @@ -129,9 +152,12 @@ describe('Acceptance: Authentication', function () { await visit('/editor'); // create the post - await fillIn('#entry-title', 'Test Post'); + await fillIn('.gh-editor-title', 'Test Post'); await fillIn('.__mobiledoc-editor', 'Test post body'); - await click('.js-publish-button'); + await triggerKeyEvent('.gh-editor-title', 'keydown', 83, { + metaKey: ctrlOrCmd === 'command', + ctrlKey: ctrlOrCmd === 'ctrl' + }); // we shouldn't have a modal at this point expect(findAll('.modal-container #login').length, 'modal exists').to.equal(0); @@ -139,8 +165,12 @@ describe('Acceptance: Authentication', function () { expect(findAll('.gh-alert').length, 'no of alerts').to.equal(0); // update the post + testOn = 'edit'; await fillIn('.__mobiledoc-editor', 'Edited post body'); - await click('.js-publish-button'); + await triggerKeyEvent('.gh-editor-title', 'keydown', 83, { + metaKey: ctrlOrCmd === 'command', + ctrlKey: ctrlOrCmd === 'ctrl' + }); // we should see a re-auth modal expect(findAll('.fullscreen-modal #login').length, 'modal exists').to.equal(1); diff --git a/ghost/admin/tests/acceptance/members/filter-test.js b/ghost/admin/tests/acceptance/members/filter-test.js index 02c5629f72d..f750af32a26 100644 --- a/ghost/admin/tests/acceptance/members/filter-test.js +++ b/ghost/admin/tests/acceptance/members/filter-test.js @@ -20,6 +20,7 @@ describe('Acceptance: Members filtering', function () { beforeEach(async function () { this.server.loadFixtures('configs'); this.server.loadFixtures('settings'); + this.server.loadFixtures('newsletters'); enableStripe(this.server); enableNewsletters(this.server, true); @@ -203,6 +204,22 @@ describe('Acceptance: Members filtering', function () { expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') .to.equal(7); + + // Can set filter by path + await visit('/'); + await visit('/members?filter=' + encodeURIComponent('subscribed:true')); + expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true - from URL') + .to.equal(3); + await click('[data-test-button="members-filter-actions"]'); + expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('true'); + + // Can set filter by path + await visit('/'); + await visit('/members?filter=' + encodeURIComponent('subscribed:false')); + expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false - from URL') + .to.equal(4); + await click('[data-test-button="members-filter-actions"]'); + expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('false'); }); it('can filter by member status', async function () { diff --git a/ghost/admin/tests/integration/components/tabs/tabs-test.js b/ghost/admin/tests/integration/components/tabs/tabs-test.js new file mode 100644 index 00000000000..bc66ddbed96 --- /dev/null +++ b/ghost/admin/tests/integration/components/tabs/tabs-test.js @@ -0,0 +1,105 @@ +import {click, findAll, render, triggerKeyEvent} from '@ember/test-helpers'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {hbs} from 'ember-cli-htmlbars'; +import {setupRenderingTest} from 'ember-mocha'; + +describe('Integration: Component: tabs/tabs', function () { + setupRenderingTest(); + + it('renders', async function () { + await render(hbs` + + Tab 1 + Tab 2 + + Content 1 + Content 2 + `); + + const tabButtons = findAll('.tab'); + const tabPanels = findAll('.tab-panel'); + + expect(findAll('.test-tab').length).to.equal(1); + expect(findAll('.tab-list').length).to.equal(1); + expect(tabPanels.length).to.equal(2); + expect(tabButtons.length).to.equal(2); + + expect(findAll('.tab-selected').length).to.equal(1); + expect(findAll('.tab-panel-selected').length).to.equal(1); + expect(tabButtons[0]).to.have.class('tab-selected'); + expect(tabPanels[0]).to.have.class('tab-panel-selected'); + + expect(tabButtons[0]).to.have.trimmed.text('Tab 1'); + expect(tabButtons[1]).to.have.trimmed.text('Tab 2'); + + expect(tabPanels[0]).to.have.trimmed.text('Content 1'); + expect(tabPanels[1]).to.have.trimmed.text(''); + }); + + it('renders expected content on click', async function () { + await render(hbs` + + Tab 1 + Tab 2 + + Content 1 + Content 2 + `); + + const tabButtons = findAll('.tab'); + const tabPanels = findAll('.tab-panel'); + + await click(tabButtons[1]); + + expect(findAll('.tab-selected').length).to.equal(1); + expect(findAll('.tab-panel-selected').length).to.equal(1); + expect(tabButtons[1]).to.have.class('tab-selected'); + expect(tabPanels[1]).to.have.class('tab-panel-selected'); + + expect(tabPanels[0]).to.have.trimmed.text(''); + expect(tabPanels[1]).to.have.trimmed.text('Content 2'); + }); + + it('renders expected content on keyup event', async function () { + await render(hbs` + + Tab 0 + Tab 1 + Tab 2 + + Content 0 + Content 1 + Content 2 + `); + + const tabButtons = findAll('.tab'); + const tabPanels = findAll('.tab-panel'); + + const isTabRenders = (num) => { + expect(tabButtons[num]).to.have.class('tab-selected'); + expect(tabPanels[num]).to.have.class('tab-panel-selected'); + + expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`); + }; + + await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight'); + await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight'); + isTabRenders(2); + + await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight'); + isTabRenders(0); + + await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft'); + isTabRenders(2); + + await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft'); + isTabRenders(1); + + await triggerKeyEvent(tabButtons[0], 'keyup', 'Home'); + isTabRenders(0); + + await triggerKeyEvent(tabButtons[0], 'keyup', 'End'); + isTabRenders(2); + }); +}); diff --git a/ghost/api-framework/package.json b/ghost/api-framework/package.json index 9b71a61a996..5fd5e69b181 100644 --- a/ghost/api-framework/package.json +++ b/ghost/api-framework/package.json @@ -20,15 +20,15 @@ "devDependencies": { "bluebird": "3.7.2", "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, "dependencies": { - "@tryghost/debug": "0.1.18", + "@tryghost/debug": "0.1.19", "@tryghost/errors": "1.2.18", "@tryghost/promise": "0.1.22", - "@tryghost/tpl": "0.1.18", + "@tryghost/tpl": "0.1.19", "@tryghost/validator": "0.1.29", "jsonpath": "1.1.1", "lodash": "4.17.21" diff --git a/ghost/api-version-compatibility-service/package.json b/ghost/api-version-compatibility-service/package.json index af88e0dfa60..c376cb99c2c 100644 --- a/ghost/api-version-compatibility-service/package.json +++ b/ghost/api-version-compatibility-service/package.json @@ -19,7 +19,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "sinon": "14.0.1" }, "dependencies": { diff --git a/ghost/audience-feedback/lib/AudienceFeedbackService.js b/ghost/audience-feedback/lib/AudienceFeedbackService.js index 3836a31d45c..66240fd4167 100644 --- a/ghost/audience-feedback/lib/AudienceFeedbackService.js +++ b/ghost/audience-feedback/lib/AudienceFeedbackService.js @@ -1,7 +1,36 @@ class AudienceFeedbackService { - buildLink() { - // todo - return new URL('https://example.com'); + /** @type URL */ + #baseURL; + /** @type {Object} */ + #urlService; + /** + * @param {object} deps + * @param {object} deps.config + * @param {URL} deps.config.baseURL + * @param {object} deps.urlService + */ + constructor(deps) { + this.#baseURL = deps.config.baseURL; + this.#urlService = deps.urlService; + } + /** + * @param {string} uuid + * @param {string} postId + * @param {0 | 1} score + */ + buildLink(uuid, postId, score) { + let postUrl = this.#urlService.getUrlByResourceId(postId, {absolute: true}); + + if (postUrl.match(/\/404\//)) { + postUrl = this.#baseURL; + } + const url = new URL(postUrl); + url.searchParams.set('action', 'feedback'); + url.searchParams.set('post', postId); + url.searchParams.set('uuid', uuid); + url.searchParams.set('score', `${score}`); + + return url; } } diff --git a/ghost/audience-feedback/package.json b/ghost/audience-feedback/package.json index 2f4d739070d..80a85a6dbdf 100644 --- a/ghost/audience-feedback/package.json +++ b/ghost/audience-feedback/package.json @@ -19,13 +19,13 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, "dependencies": { "@tryghost/errors": "1.2.18", - "@tryghost/tpl": "0.1.18", + "@tryghost/tpl": "0.1.19", "bson-objectid": "2.0.3" } } diff --git a/ghost/audience-feedback/test/AudienceFeedbackService.test.js b/ghost/audience-feedback/test/AudienceFeedbackService.test.js new file mode 100644 index 00000000000..fb88c4a8256 --- /dev/null +++ b/ghost/audience-feedback/test/AudienceFeedbackService.test.js @@ -0,0 +1,45 @@ +const assert = require('assert'); +const {AudienceFeedbackService} = require('../index'); + +describe('audienceFeedbackService', function () { + it('exported', function () { + assert.equal(require('../index').AudienceFeedbackService, AudienceFeedbackService); + }); + + const mockData = { + uuid: '7b11de3c-dff9-4563-82ae-a281122d201d', + postId: '634fc3901e0a291855d8b135', + postTitle: 'somepost', + score: 1 + }; + + describe('build link', function () { + it('Can build link to post', async function () { + const instance = new AudienceFeedbackService({ + urlService: { + getUrlByResourceId: () => `https://localhost:2368/${mockData.postTitle}/` + }, + config: { + baseURL: new URL('https://localhost:2368') + } + }); + const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score); + const expectedLink = `https://localhost:2368/${mockData.postTitle}/?action=feedback&post=${mockData.postId}&uuid=${mockData.uuid}&score=${mockData.score}`; + assert.equal(link.href, expectedLink); + }); + + it('Can build link to home page if post wasn\'t published', async function () { + const instance = new AudienceFeedbackService({ + urlService: { + getUrlByResourceId: () => `https://localhost:2368/${mockData.postTitle}/404/` + }, + config: { + baseURL: new URL('https://localhost:2368') + } + }); + const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score); + const expectedLink = `https://localhost:2368/?action=feedback&post=${mockData.postId}&uuid=${mockData.uuid}&score=${mockData.score}`; + assert.equal(link.href, expectedLink); + }); + }); +}); diff --git a/ghost/audience-feedback/test/hello.test.js b/ghost/audience-feedback/test/hello.test.js deleted file mode 100644 index 85d69d1e08c..00000000000 --- a/ghost/audience-feedback/test/hello.test.js +++ /dev/null @@ -1,10 +0,0 @@ -// Switch these lines once there are useful utils -// const testUtils = require('./utils'); -require('./utils'); - -describe('Hello world', function () { - it('Runs a test', function () { - // TODO: Write me! - 'hello'.should.eql('hello'); - }); -}); diff --git a/ghost/bootstrap-socket/package.json b/ghost/bootstrap-socket/package.json index 64cafbd61b2..d5c8e42d317 100644 --- a/ghost/bootstrap-socket/package.json +++ b/ghost/bootstrap-socket/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" }, diff --git a/ghost/constants/package.json b/ghost/constants/package.json index 27d1c9d1110..841153cde52 100644 --- a/ghost/constants/package.json +++ b/ghost/constants/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "c8": "7.12.0", - "mocha": "10.0.0", + "mocha": "10.1.0", "should": "13.2.3", "sinon": "14.0.1" } diff --git a/ghost/core/core/frontend/public/robots.txt b/ghost/core/core/frontend/public/robots.txt index d3e413eeb5a..5895cc5cdaa 100644 --- a/ghost/core/core/frontend/public/robots.txt +++ b/ghost/core/core/frontend/public/robots.txt @@ -3,3 +3,4 @@ Sitemap: {{blog-url}}/sitemap.xml Disallow: /ghost/ Disallow: /p/ Disallow: /email/ +Disallow: /r/ diff --git a/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js b/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js index c7045d30f6b..6f494369a49 100644 --- a/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js +++ b/ghost/core/core/frontend/src/comment-counts/js/comment-counts.js @@ -63,6 +63,10 @@ const ids = Array.from(fetchingIds); fetchingIds.clear(); + if (!ids.length) { + return; + } + const rawRes = await fetch(api, { method: 'POST', headers: { @@ -72,6 +76,10 @@ body: JSON.stringify({ids}) }); + if (rawRes.status !== 200) { + return; + } + const res = await rawRes.json(); for (const [id, count] of Object.entries(res)) { diff --git a/ghost/core/core/server/api/endpoints/links.js b/ghost/core/core/server/api/endpoints/links.js index cd42e200472..6af56a4a527 100644 --- a/ghost/core/core/server/api/endpoints/links.js +++ b/ghost/core/core/server/api/endpoints/links.js @@ -6,7 +6,7 @@ module.exports = { options: [ 'filter' ], - permissions: false, + permissions: true, async query(frame) { const links = await linkTrackingService.service.getLinks(frame.options); @@ -21,5 +21,37 @@ module.exports = { } }; } + }, + bulkEdit: { + statusCode: 200, + headers: { + cacheInvalidate: true + }, + options: [ + 'filter' + ], + data: [ + 'action', + 'meta' + ], + validation: { + data: { + action: { + required: true, + values: ['updateLink'] + } + }, + options: { + filter: { + required: true + } + } + }, + permissions: { + method: 'edit' + }, + async query(frame) { + return await linkTrackingService.service.bulkEdit(frame.data.bulk, frame.options); + } } }; diff --git a/ghost/core/core/server/api/endpoints/members.js b/ghost/core/core/server/api/endpoints/members.js index 9cee71bab74..30dbba7b13f 100644 --- a/ghost/core/core/server/api/endpoints/members.js +++ b/ghost/core/core/server/api/endpoints/members.js @@ -435,10 +435,7 @@ module.exports = { method: 'browse' }, async query(frame) { - const events = await membersService.api.events.getEventTimeline(frame.options); - return { - events - }; + return await membersService.api.events.getEventTimeline(frame.options); } } }; diff --git a/ghost/core/core/server/api/endpoints/posts-public.js b/ghost/core/core/server/api/endpoints/posts-public.js index a9eb750eb64..0da7e6aaaf3 100644 --- a/ghost/core/core/server/api/endpoints/posts-public.js +++ b/ghost/core/core/server/api/endpoints/posts-public.js @@ -1,7 +1,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const allowedIncludes = ['tags', 'authors', 'tiers']; +const allowedIncludes = ['tags', 'authors', 'tiers', 'sentiment']; const messages = { postNotFound: 'Post not found.' diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index f7de213ad60..d854d569d82 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -11,7 +11,8 @@ const allowedIncludes = [ 'newsletter', 'count.signups', 'count.paid_conversions', - 'count.clicks' + 'count.clicks', + 'sentiment' ]; const unsafeAttrs = ['status', 'authors', 'visibility']; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js index e1232660592..178009a5126 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js @@ -16,7 +16,26 @@ function removeSourceFormats(frame) { } } +/** + * Map names of relations to the internal names + */ +function mapWithRelated(frame) { + if (frame.options.withRelated) { + // Map sentiment to count.sentiment + if (labs.isSet('audienceFeedback')) { + frame.options.withRelated = frame.options.withRelated.map((relation) => { + return relation === 'sentiment' ? 'count.sentiment' : relation; + }); + } + return; + } +} + function defaultRelations(frame) { + // Apply same mapping as content API + mapWithRelated(frame); + + // Addditional defaults for admin API if (frame.options.withRelated) { return; } @@ -26,7 +45,7 @@ function defaultRelations(frame) { } if (labs.isSet('audienceFeedback')) { - frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.paid_conversions', 'count.clicks', 'count.sentiment', 'count.positive_feedback']; + frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.conversions', 'count.clicks', 'count.sentiment', 'count.positive_feedback', 'count.negative_feedback']; } else { frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.paid_conversions', 'count.clicks']; } @@ -111,6 +130,7 @@ module.exports = { setDefaultOrder(frame); forceVisibilityColumn(frame); + mapWithRelated(frame); } if (!localUtils.isContentAPI(frame)) { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js index 7dc5768b643..d23d86a4d32 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/index.js @@ -127,5 +127,9 @@ module.exports = { get members_stripe_connect() { return require('./members-stripe-connect'); + }, + + get links() { + return require('./links'); } }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js new file mode 100644 index 00000000000..c030f8c44a3 --- /dev/null +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/links.js @@ -0,0 +1,5 @@ +module.exports = { + bulkEdit(data, apiConfig, frame) { + frame.response = data; + } +}; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js index f659569f7fb..f2e1ff24fc3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js @@ -2,6 +2,21 @@ const mapComment = require('./comments'); const url = require('../utils/url'); const _ = require('lodash'); +const memberFields = [ + 'id', + 'uuid', + 'name', + 'email', + 'avatar_image' +]; + +const postFields = [ + 'id', + 'uuid', + 'title', + 'url' +]; + const commentEventMapper = (json, frame) => { return { ...json, @@ -10,26 +25,11 @@ const commentEventMapper = (json, frame) => { }; const clickEventMapper = (json, frame) => { - const memberFields = [ - 'id', - 'uuid', - 'name', - 'email', - 'avatar_image' - ]; - const linkFields = [ 'from', 'to' ]; - const postFields = [ - 'id', - 'uuid', - 'title', - 'url' - ]; - const data = json.data; const response = {}; @@ -59,6 +59,35 @@ const clickEventMapper = (json, frame) => { }; }; +const feedbackEventMapper = (json, frame) => { + const feedbackFields = [ + 'id', + 'score', + 'created_at' + ]; + + const data = json.data; + const response = _.pick(data, feedbackFields); + + if (data.post) { + url.forPost(data.post.id, data.post, frame); + response.post = _.pick(data.post, postFields); + } else { + response.post = null; + } + + if (data.member) { + response.member = _.pick(data.member, memberFields); + } else { + response.member = null; + } + + return { + ...json, + data: response + }; +}; + function serializeAttribution(attribution) { if (!attribution) { return attribution; @@ -82,6 +111,9 @@ const activityFeedMapper = (event, frame) => { if (event.type === 'click_event') { return clickEventMapper(event, frame); } + if (event.type === 'feedback_event') { + return feedbackEventMapper(event, frame); + } if (event.data?.attribution) { event.data.attribution = serializeAttribution(event.data.attribution); } diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js index 75dfcc305ad..bb7ceeee1e3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/emails.js @@ -8,7 +8,7 @@ module.exports = (model, frame) => { const replacements = mega.postEmailSerializer.parseReplacements(jsonModel); replacements.forEach((replacement) => { jsonModel[replacement.format] = jsonModel[replacement.format].replace( - replacement.match, + replacement.regexp, replacement.fallback || '' ); }); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js index ed97dca1789..2083347db3d 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js @@ -119,13 +119,30 @@ module.exports = async (model, frame, options = {}) => { ); } - if (jsonModel.count && !jsonModel.count.sentiment) { - jsonModel.count.sentiment = 0; + // The sentiment has been loaded as a count relation in count.sentiment. But externally in the API we use just 'sentiment' instead of count.sentiment + // This part moves count.sentiment to just 'sentiment' when it has been loaded + if (frame.options.withRelated && frame.options.withRelated.includes('count.sentiment')) { + if (!jsonModel.count) { + jsonModel.sentiment = 0; + } else { + jsonModel.sentiment = jsonModel.count.sentiment ?? 0; + + // Delete it from the original location + delete jsonModel.count.sentiment; + + if (Object.keys(jsonModel.count).length === 0) { + delete jsonModel.count; + } + } } if (jsonModel.count && !jsonModel.count.positive_feedback) { jsonModel.count.positive_feedback = 0; } + if (jsonModel.count && !jsonModel.count.negative_feedback) { + jsonModel.count.negative_feedback = 0; + } + return jsonModel; }; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js index 6331d6e3e9a..0e5ae4adb4a 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js @@ -22,8 +22,8 @@ module.exports = (snippet, frame) => { /** * @typedef {Object} SerializedSnippet * @prop {string} id - * @prop {string=} name - * @prop {string=} mobiledoc + * @prop {string} [name] + * @prop {string} [mobiledoc] * @prop {string} created_at * @prop {string} updated_at * @prop {string} created_by diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js index 7e3d689030d..88f61759c22 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/members.js @@ -76,11 +76,12 @@ function bulkAction(bulkActionResult, _apiConfig, frame) { /** * - * @returns {{events: any[]}} + * @returns {{events: any[], meta: any}} */ function activityFeed(data, _apiConfig, frame) { return { - events: data.events.map(e => mappers.activityFeedEvents(e, frame)) + events: data.events.map(e => mappers.activityFeedEvents(e, frame)), + meta: data.meta }; } @@ -216,15 +217,15 @@ function createSerializer(debugString, serialize) { * @prop {string} id * @prop {string} uuid * @prop {string} email - * @prop {string=} name - * @prop {string=} note + * @prop {string} [name] + * @prop {string} [note] * @prop {null|string} geolocation * @prop {boolean} subscribed * @prop {string} created_at * @prop {string} updated_at * @prop {string[]} labels * @prop {SerializedMemberStripeSubscription[]} subscriptions - * @prop {SerializedMemberProduct[]=} products + * @prop {SerializedMemberProduct[]} [products] * @prop {string} avatar_image * @prop {boolean} comped * @prop {number} email_count diff --git a/ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js b/ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js new file mode 100644 index 00000000000..55283e1ba22 --- /dev/null +++ b/ghost/core/core/server/data/importer/importers/data/custom-theme-settings.js @@ -0,0 +1,81 @@ +const _ = require('lodash'); +const Promise = require('bluebird'); +const debug = require('@tryghost/debug')('importer:roles'); +const BaseImporter = require('./base'); +const models = require('../../../../models'); +const {activate} = require('../../../../services/themes/activate'); + +class CustomThemeSettingsImporter extends BaseImporter { + constructor(allDataFromFile) { + super(allDataFromFile, { + modelName: 'CustomThemeSetting', + dataKeyToImport: 'custom_theme_settings' + }); + } + + beforeImport() { + debug('beforeImport'); + return super.beforeImport(); + } + + doImport(options, importOptions) { + debug('doImport', this.modelName, this.dataToImport.length); + + let ops = []; + + _.each(this.dataToImport, (item) => { + ops.push(models.CustomThemeSetting.findOne({theme: item.theme, key: item.key}, options) + .then((setting) => { + if (_.isObject(item.value)) { + item.value = JSON.stringify(item.value); + } + + if (setting) { + setting.set('value', item.value); + if (setting.hasChanged()) { + return setting.save(null, options) + .then((importedModel) => { + if (importOptions.returnImportedData) { + this.importedDataToReturn.push(importedModel.toJSON()); + } + return importedModel; + }) + .catch((err) => { + return this.handleError(err, item); + }); + } + + return Promise.resolve(); + } + + return models.CustomThemeSetting.add(item, options) + .then((importedModel) => { + if (importOptions.returnImportedData) { + this.importedDataToReturn.push(importedModel.toJSON()); + } + return importedModel; + }) + .catch((err) => { + return this.handleError(err, item); + }); + }) + .reflect()); + }); + + const opsPromise = Promise.all(ops); + + // activate function is called to refresh cache when importing custom theme settings for active theme + opsPromise.then(() => { + models.Settings.findOne({key: 'active_theme'}) + .then((theme) => { + const currentTheme = theme.get('value'); + if (this.dataToImport.some(themeSetting => themeSetting.theme === currentTheme)) { + activate(currentTheme); + } + }); + }); + + return opsPromise; + } +} +module.exports = CustomThemeSettingsImporter; \ No newline at end of file diff --git a/ghost/core/core/server/data/importer/importers/data/data-importer.js b/ghost/core/core/server/data/importer/importers/data/data-importer.js index cff048d567f..9c746870327 100644 --- a/ghost/core/core/server/data/importer/importers/data/data-importer.js +++ b/ghost/core/core/server/data/importer/importers/data/data-importer.js @@ -13,6 +13,7 @@ const NewslettersImporter = require('./newsletters'); const ProductsImporter = require('./products'); const StripeProductsImporter = require('./stripe-products'); const StripePricesImporter = require('./stripe-prices'); +const CustomThemeSettingsImporter = require('./custom-theme-settings'); const RolesImporter = require('./roles'); let importers = {}; let DataImporter; @@ -35,6 +36,7 @@ DataImporter = { importers.stripe_products = new StripeProductsImporter(importData.data); importers.stripe_prices = new StripePricesImporter(importData.data); importers.posts = new PostsImporter(importData.data); + importers.custom_theme_settings = new CustomThemeSettingsImporter(importData.data); return importData; }, diff --git a/ghost/core/core/server/data/migrations/utils/permissions.js b/ghost/core/core/server/data/migrations/utils/permissions.js index 66b2685e39b..2982770df6d 100644 --- a/ghost/core/core/server/data/migrations/utils/permissions.js +++ b/ghost/core/core/server/data/migrations/utils/permissions.js @@ -10,6 +10,10 @@ const messages = { permissionRoleActionError: 'Cannot {action} permission({permission}) with role({role}) - {resource} does not exist' }; +/** + * @param {import('knex').Knex} connection + * @param {PermissionConfig} config + */ async function addPermissionHelper(connection, config) { const existingPermission = await connection('permissions').where({ name: config.name, @@ -38,6 +42,10 @@ async function addPermissionHelper(connection, config) { }); } +/** + * @param {import('knex').Knex} connection + * @param {PermissionConfig} config + */ async function removePermissionHelper(connection, config) { const existingPermission = await connection('permissions').where({ name: config.name, @@ -61,10 +69,7 @@ async function removePermissionHelper(connection, config) { /** * Creates a migration which will add a permission to the database * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission + * @param {PermissionConfig} config * * @returns {Migration} */ @@ -82,10 +87,7 @@ function addPermission(config) { /** * Creates a migration which will remove a permission from the database * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission + * @param {PermissionConfig} config * * @returns {Migration} */ @@ -100,6 +102,10 @@ function removePermission(config) { ); } +/** + * @param {import('knex').Knex} connection + * @param {PermissionRoleConfig} config + */ async function addPermissionToRoleHelper(connection, config) { const permission = await connection('permissions').where({ name: config.permission @@ -149,6 +155,10 @@ async function addPermissionToRoleHelper(connection, config) { }); } +/** + * @param {import('knex').Knex} connection + * @param {PermissionRoleConfig} config + */ async function removePermissionFromRoleHelper(connection, config) { const permission = await connection('permissions').where({ name: config.permission @@ -188,9 +198,7 @@ async function removePermissionFromRoleHelper(connection, config) { /** * Creates a migration which will link a permission to a role in the database * - * @param {Object} config - * @param {string} config.permission - The name of the permission - * @param {string} config.role - The name of the role + * @param {PermissionRoleConfig} config * * @returns {Migration} */ @@ -208,9 +216,7 @@ function addPermissionToRole(config) { /** * Creates a migration which will remove the permission from roles * - * @param {Object} config - * @param {string} config.permission - The name of the permission - * @param {string} config.role - The name of the role + * @param {PermissionRoleConfig} config * * @returns {Migration} */ @@ -228,11 +234,7 @@ function removePermissionFromRole(config) { /** * Creates a migration which will add a permission to the database, and then link it to roles * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission - * + * @param {PermissionConfig} config * @param {string[]} roles - A list of role names * * @returns {Migration} @@ -247,11 +249,7 @@ function addPermissionWithRoles(config, roles) { /** * Creates a migration which will remove permissions from roles, and then remove the permission * - * @param {Object} config - * @param {string} config.name - The name of the permission - * @param {string} config.action - The action_type of the permission - * @param {string} config.object - The object_type of the permission - * + * @param {PermissionConfig} config * @param {string[]} roles - A list of role names * * @returns {Migration} @@ -270,6 +268,19 @@ module.exports = { createRemovePermissionMigration }; +/** + * @typedef {Object} PermissionConfig + * @prop {string} config.name - The name of the permission + * @prop {string} config.action - The action_type of the permission + * @prop {string} config.object - The object_type of the permission + */ + +/** + * @typedef {Object} PermissionRoleConfig + * @prop {string} config.permission - The name of the permission + * @prop {string} config.role - The role to assign the Permission to + */ + /** * @typedef {Object} TransactionalMigrationFunctionOptions * diff --git a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js new file mode 100644 index 00000000000..fad8edc59b6 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-25-add-columns-to-products-table.js @@ -0,0 +1,19 @@ +const {createAddColumnMigration, combineNonTransactionalMigrations} = require('../../utils'); + +module.exports = combineNonTransactionalMigrations( + createAddColumnMigration('products', 'monthly_price', { + type: 'integer', + unsigned: true, + nullable: true + }), + createAddColumnMigration('products', 'yearly_price', { + type: 'integer', + unsigned: true, + nullable: true + }), + createAddColumnMigration('products', 'currency', { + type: 'string', + maxlength: 50, + nullable: true + }) +); diff --git a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js new file mode 100644 index 00000000000..565294ce4b7 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js @@ -0,0 +1,37 @@ +const logging = require('@tryghost/logging'); + +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(knex) { + const rows = await knex('products as t') // eslint-disable-line no-restricted-syntax + .select( + 't.id as id', + 'mp.amount as monthly_price', + 'yp.amount as yearly_price', + knex.raw('coalesce(yp.currency, mp.currency) as currency') + ) + .leftJoin('stripe_prices AS mp', 't.monthly_price_id', 'mp.id') + .leftJoin('stripe_prices AS yp', 't.yearly_price_id', 'yp.id') + .where('t.type', 'paid'); + + if (!rows.length) { + logging.info('Did not find any active paid Tiers'); + return; + } else { + logging.info(`Updating ${rows.length} Tiers with price and currency information`); + } + + for (const row of rows) { // eslint-disable-line no-restricted-syntax + await knex('products').update(row).where('id', row.id); + } + }, + async function down(knex) { + logging.info('Removing currency and price information for all tiers'); + await knex('products').update({ + currency: null, + monthly_price: null, + yearly_price: null + }); + } +); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js new file mode 100644 index 00000000000..937753ee098 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js @@ -0,0 +1,3 @@ +const {createDropNullableMigration} = require('../../utils'); + +module.exports = createDropNullableMigration('subscriptions', 'tier_id'); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js new file mode 100644 index 00000000000..e852d02ea6b --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js @@ -0,0 +1,10 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('members_stripe_customers_subscriptions', 'ghost_subscription_id', { + type: 'string', + maxlength: 24, + nullable: true, + references: 'subscriptions.id', + constraintName: 'mscs_ghost_subscription_id_foreign', + cascadeDelete: true +}); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js new file mode 100644 index 00000000000..7ffd3d34dec --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js @@ -0,0 +1,10 @@ +const {addPermissionWithRoles} = require('../../utils'); + +module.exports = addPermissionWithRoles({ + name: 'Browse links', + action: 'browse', + object: 'link' +}, [ + 'Administrator', + 'Admin Integration' +]); diff --git a/ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js new file mode 100644 index 00000000000..db5d83cfe37 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js @@ -0,0 +1,10 @@ +const {addPermissionWithRoles} = require('../../utils'); + +module.exports = addPermissionWithRoles({ + name: 'Edit links', + action: 'edit', + object: 'link' +}, [ + 'Administrator', + 'Admin Integration' +]); diff --git a/ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js new file mode 100644 index 00000000000..f3ad8ee3a34 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js @@ -0,0 +1,20 @@ +const logging = require('@tryghost/logging'); +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(connection) { + const affectedRows = await connection('newsletters') + .update({ + feedback_enabled: false + }) + .where('feedback_enabled', true); + + if (affectedRows > 0) { + // Only log if this site was affected by the issue. + logging.info(`Disabled feedback for ${affectedRows} newsletter(s)`); + } + }, + async function down() { + // no-op: we don't need to change it back + } +); diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index 04dc071cb98..5f748fea41a 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -14,20 +14,26 @@ const messages = { noSupportForDatabase: 'No support for database client {client}' }; -function addTableColumn(tableName, table, columnName, columnSpec = schema[tableName][columnName]) { +/** + * @param {string} tableName + * @param {import('knex').knex.TableBuilder} tableBuilder + * @param {string} columnName + * @param {object} [columnSpec] + */ +function addTableColumn(tableName, tableBuilder, columnName, columnSpec = schema[tableName][columnName]) { let column; // creation distinguishes between text with fieldtype, string with maxlength and all others if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) { - column = table[columnSpec.type](columnName, columnSpec.fieldtype); + column = tableBuilder[columnSpec.type](columnName, columnSpec.fieldtype); } else if (columnSpec.type === 'string') { if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) { - column = table[columnSpec.type](columnName, columnSpec.maxlength); + column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength); } else { - column = table[columnSpec.type](columnName, 191); + column = tableBuilder[columnSpec.type](columnName, 191); } } else { - column = table[columnSpec.type](columnName); + column = tableBuilder[columnSpec.type](columnName); } if (Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && columnSpec.nullable === true) { @@ -48,6 +54,10 @@ function addTableColumn(tableName, table, columnName, columnSpec = schema[tableN // check if table exists? column.references(columnSpec.references); } + if (Object.prototype.hasOwnProperty.call(columnSpec, 'constraintName')) { + column.withKeyName(columnSpec.constraintName); + } + if (Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) { column.onDelete('CASCADE'); } else if (Object.prototype.hasOwnProperty.call(columnSpec, 'setNullDelete') && columnSpec.setNullDelete === true) { @@ -61,18 +71,34 @@ function addTableColumn(tableName, table, columnName, columnSpec = schema[tableN } } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex} [transaction] + */ function setNullable(tableName, column, transaction = db.knex) { return transaction.schema.table(tableName, function (table) { table.setNullable(column); }); } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex} [transaction] + */ function dropNullable(tableName, column, transaction = db.knex) { return transaction.schema.table(tableName, function (table) { table.dropNullable(column); }); } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex.Transaction} [transaction] + * @param {object} columnSpec + */ async function addColumn(tableName, column, transaction = db.knex, columnSpec) { const addColumnBuilder = transaction.schema.table(tableName, function (table) { addTableColumn(tableName, table, column, columnSpec); @@ -85,41 +111,51 @@ async function addColumn(tableName, column, transaction = db.knex, columnSpec) { return; } - let sql = addColumnBuilder.toSQL()[0].sql; + for (const sqlQuery of addColumnBuilder.toSQL()) { + let sql = sqlQuery.sql; - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; - } + if (DatabaseInfo.isMySQL(transaction)) { + // Guard against an ending semicolon + sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; + } - await transaction.raw(sql); + await transaction.raw(sql); + } } +/** + * @param {string} tableName + * @param {string} column + * @param {import('knex').Knex} [transaction] + * @param {object} [columnSpec] + */ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = {}) { if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) { const [toTable, toColumn] = columnSpec.references.split('.'); - await dropForeign({fromTable: tableName, fromColumn: column, toTable, toColumn, transaction}); + await dropForeign({fromTable: tableName, fromColumn: column, toTable, toColumn, constraintName: columnSpec.constraintName, transaction}); } - const dropTableBuilder = transaction.schema.table(tableName, function (table) { + const dropColumnBuilder = transaction.schema.table(tableName, function (table) { table.dropColumn(column); }); // Use the default flow for SQLite because .toSQL() is tricky with SQLite when // it does the table dance if (DatabaseInfo.isSQLite(transaction)) { - await dropTableBuilder; + await dropColumnBuilder; return; } - let sql = dropTableBuilder.toSQL()[0].sql; + for (const sqlQuery of dropColumnBuilder.toSQL()) { + let sql = sqlQuery.sql; - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; - } + if (DatabaseInfo.isMySQL(transaction)) { + // Guard against an ending semicolon + sql = sql.replace(/;\s*$/, '') + ', algorithm=copy'; + } - await transaction.raw(sql); + await transaction.raw(sql); + } } /** @@ -127,7 +163,7 @@ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = * * @param {string} tableName - name of the table to add unique constraint to * @param {string|string[]} columns - column(s) to form unique constraint with - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} [transaction] - connection object containing knex reference */ async function addUnique(tableName, columns, transaction = db.knex) { try { @@ -154,7 +190,7 @@ async function addUnique(tableName, columns, transaction = db.knex) { * * @param {string} tableName - name of the table to drop unique constraint from * @param {string|string[]} columns - column(s) unique constraint was formed - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} transaction - connection object containing knex reference */ async function dropUnique(tableName, columns, transaction = db.knex) { try { @@ -184,7 +220,7 @@ async function dropUnique(tableName, columns, transaction = db.knex) { * @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to - * @param {import('knex')} configuration.transaction - connection object containing knex reference + * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference */ async function hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction = db.knex}) { if (!DatabaseInfo.isSQLite(transaction)) { @@ -208,11 +244,12 @@ async function hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, trans * @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to + * @param {string} [configuration.constraintName] - name of the FK to create * @param {Boolean} [configuration.cascadeDelete] - adds the "on delete cascade" option if true * @param {Boolean} [configuration.setNullDelete] - adds the "on delete SET NULL" option if true - * @param {import('knex')} configuration.transaction - connection object containing knex reference + * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference */ -async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDelete = false, setNullDelete = false, transaction = db.knex}) { +async function addForeign({fromTable, fromColumn, toTable, toColumn, constraintName, cascadeDelete = false, setNullDelete = false, transaction = db.knex}) { if (DatabaseInfo.isSQLite(transaction)) { const foreignKeyExists = await hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction}); if (foreignKeyExists) { @@ -233,12 +270,18 @@ async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDele } await transaction.schema.table(fromTable, function (table) { + let fkBuilder; + if (cascadeDelete) { - table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('CASCADE'); + fkBuilder = table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('CASCADE'); } else if (setNullDelete) { - table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('SET NULL'); + fkBuilder = table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('SET NULL'); } else { - table.foreign(fromColumn).references(`${toTable}.${toColumn}`); + fkBuilder = table.foreign(fromColumn).references(`${toTable}.${toColumn}`); + } + + if (constraintName) { + fkBuilder.withKeyName(constraintName); } }); @@ -264,9 +307,10 @@ async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDele * @param {string} configuration.fromColumn - column of the table to add the foreign key to * @param {string} configuration.toTable - name of the table to point the foreign key to * @param {string} configuration.toColumn - column of the table to point the foreign key to - * @param {import('knex')} configuration.transaction - connection object containing knex reference + * @param {string} [configuration.constraintName] - name of the FK to delete + * @param {import('knex').Knex} [configuration.transaction] - connection object containing knex reference */ -async function dropForeign({fromTable, fromColumn, toTable, toColumn, transaction = db.knex}) { +async function dropForeign({fromTable, fromColumn, toTable, toColumn, constraintName, transaction = db.knex}) { if (DatabaseInfo.isSQLite(transaction)) { const foreignKeyExists = await hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction}); if (!foreignKeyExists) { @@ -287,7 +331,7 @@ async function dropForeign({fromTable, fromColumn, toTable, toColumn, transactio } await transaction.schema.table(fromTable, function (table) { - table.dropForeign(fromColumn); + table.dropForeign(fromColumn, constraintName); }); if (DatabaseInfo.isSQLite(transaction)) { @@ -308,7 +352,7 @@ async function dropForeign({fromTable, fromColumn, toTable, toColumn, transactio * Checks if primary key index exists in a table over the given columns. * * @param {string} tableName - name of the table to check primary key constraint on - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} [transaction] - connection object containing knex reference */ async function hasPrimaryKeySQLite(tableName, transaction = db.knex) { if (!DatabaseInfo.isSQLite(transaction)){ @@ -328,7 +372,7 @@ async function hasPrimaryKeySQLite(tableName, transaction = db.knex) { * * @param {string} tableName - name of the table to add primaykey constraint to * @param {string|string[]} columns - column(s) to form primary key constraint with - * @param {import('knex')} transaction - connection object containing knex reference + * @param {import('knex').Knex} [transaction] - connection object containing knex reference */ async function addPrimaryKey(tableName, columns, transaction = db.knex) { if (DatabaseInfo.isSQLite(transaction)) { @@ -359,7 +403,7 @@ async function addPrimaryKey(tableName, columns, transaction = db.knex) { * utils if you want that * * @param {String} table - name of the table to create - * @param {import('knex').Transaction} transaction - connection to the DB + * @param {import('knex').Knex} [transaction] - connection to the DB * @param {Object} [tableSpec] - table schema to generate table with */ function createTable(table, transaction = db.knex, tableSpec = schema[table]) { @@ -377,10 +421,17 @@ function createTable(table, transaction = db.knex, tableSpec = schema[table]) { }); } +/** + * @param {string} table + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function deleteTable(table, transaction = db.knex) { return transaction.schema.dropTableIfExists(table); } +/** + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function getTables(transaction = db.knex) { const client = transaction.client.config.client; @@ -391,6 +442,10 @@ function getTables(transaction = db.knex) { return Promise.reject(tpl(messages.noSupportForDatabase, {client: client})); } +/** + * @param {string} table + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function getIndexes(table, transaction = db.knex) { const client = transaction.client.config.client; @@ -401,6 +456,10 @@ function getIndexes(table, transaction = db.knex) { return Promise.reject(tpl(messages.noSupportForDatabase, {client: client})); } +/** + * @param {string} table + * @param {import('knex').Knex} [transaction] - connection to the DB + */ function getColumns(table, transaction = db.knex) { const client = transaction.client.config.client; @@ -441,20 +500,20 @@ function createColumnMigration(...migrations) { } module.exports = { - createTable: createTable, - deleteTable: deleteTable, - getTables: getTables, - getIndexes: getIndexes, - addUnique: addUnique, - dropUnique: dropUnique, - addPrimaryKey: addPrimaryKey, - addForeign: addForeign, - dropForeign: dropForeign, - addColumn: addColumn, - dropColumn: dropColumn, - setNullable: setNullable, - dropNullable: dropNullable, - getColumns: getColumns, + createTable, + deleteTable, + getTables, + getIndexes, + addUnique, + dropUnique, + addPrimaryKey, + addForeign, + dropForeign, + addColumn, + dropColumn, + setNullable, + dropNullable, + getColumns, createColumnMigration, // NOTE: below are exposed for testing purposes only _hasForeignSQLite: hasForeignSQLite, diff --git a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js index cb67a831b77..02f86c1d3e1 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js +++ b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js @@ -1,5 +1,4 @@ const _ = require('lodash'); -const Promise = require('bluebird'); const logging = require('@tryghost/logging'); const {sequence} = require('@tryghost/promise'); @@ -83,16 +82,16 @@ class FixtureManager { const userRolesRelation = this.fixtures.relations.find(r => r.from.relation === 'roles'); await this.addFixturesForRelation(userRolesRelation, localOptions); - await Promise.mapSeries(this.fixtures.models.filter(m => !['User', 'Role'].includes(m.name)), (model) => { + await sequence(this.fixtures.models.filter(m => !['User', 'Role'].includes(m.name)).map(model => () => { logging.info('Model: ' + model.name); return this.addFixturesForModel(model, localOptions); - }); + })); - await Promise.mapSeries(this.fixtures.relations.filter(r => r.from.relation !== 'roles'), (relation) => { + await sequence(this.fixtures.relations.filter(r => r.from.relation !== 'roles').map(relation => () => { logging.info('Relation: ' + relation.from.model + ' to ' + relation.to.model); return this.addFixturesForRelation(relation, localOptions); - }); + })); } /* @@ -191,12 +190,15 @@ class FixtureManager { fetchRelationData(relation, options) { const fromOptions = _.extend({}, options, {withRelated: [relation.from.relation]}); - const props = { - from: models[relation.from.model].findAll(fromOptions), - to: models[relation.to.model].findAll(options) - }; + const fromRelations = models[relation.from.model].findAll(fromOptions); + const toRelations = models[relation.to.model].findAll(options); - return Promise.props(props); + return Promise.all([fromRelations, toRelations]).then(([from, to]) => { + return { + from: from, + to: to + }; + }); } /** @@ -223,7 +225,7 @@ class FixtureManager { }); } - const results = await Promise.mapSeries(modelFixture.entries, async (entry) => { + const results = await sequence(modelFixture.entries.map(entry => async () => { let data = {}; // CASE: if id is specified, only query by id @@ -243,7 +245,7 @@ class FixtureManager { if (!found) { return models[modelFixture.name].add(entry, options); } - }); + })); return {expected: modelFixture.entries.length, done: _.compact(results).length}; } @@ -308,12 +310,12 @@ class FixtureManager { } async removeFixturesForModel(modelFixture, options) { - const results = await Promise.mapSeries(modelFixture.entries, async (entry) => { + const results = await sequence(modelFixture.entries.map(entry => async () => { const found = models[modelFixture.name].findOne(entry.id ? {id: entry.id} : entry, options); if (found) { return models[modelFixture.name].destroy(_.extend(options, {id: found.id})); } - }); + })); return {expected: modelFixture.entries.length, done: results.length}; } diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index d03a57823a1..46f8dd717f3 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -15,7 +15,10 @@ "slug": "default-product", "type": "paid", "active": true, - "visibility": "public" + "visibility": "public", + "currency": "usd", + "monthly_price": 500, + "yearly_price": 5000 } ] }, @@ -615,6 +618,16 @@ "name": "Report comments", "action_type": "report", "object_type": "comment" + }, + { + "name": "Browse links", + "action_type": "browse", + "object_type": "link" + }, + { + "name": "Edit links", + "action_type": "edit", + "object_type": "link" } ] }, @@ -744,7 +757,8 @@ "members_stripe_connect": "auth", "newsletter": "all", "explore": "read", - "comment": "all" + "comment": "all", + "link": "all" }, "DB Backup Integration": { "db": "all" @@ -778,7 +792,8 @@ "offer": ["browse", "read", "add", "edit"], "newsletter": ["browse", "read", "add", "edit"], "explore": "read", - "comment": "all" + "comment": "all", + "link": "all" }, "Editor": { "notification": "all", diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index cb2d0dfbde0..567b9228cc6 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -435,12 +435,24 @@ module.exports = { validations: {isIn: [['public', 'none']]} }, trial_days: {type: 'integer', unsigned: true, nullable: false, defaultTo: 0}, - monthly_price_id: {type: 'string', maxlength: 24, nullable: true}, - yearly_price_id: {type: 'string', maxlength: 24, nullable: true}, description: {type: 'string', maxlength: 191, nullable: true}, - type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'paid', validations: {isIn: [['paid', 'free']]}}, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'paid', + validations: { + isIn: [['paid', 'free']] + } + }, + currency: {type: 'string', maxlength: 50, nullable: true}, + monthly_price: {type: 'integer', unsigned: true, nullable: true}, + yearly_price: {type: 'integer', unsigned: true, nullable: true}, created_at: {type: 'dateTime', nullable: false}, - updated_at: {type: 'dateTime', nullable: true} + updated_at: {type: 'dateTime', nullable: true}, + // To be removed in future + monthly_price_id: {type: 'string', maxlength: 24, nullable: true}, + yearly_price_id: {type: 'string', maxlength: 24, nullable: true} }, offers: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, @@ -618,7 +630,7 @@ module.exports = { } }, member_id: {type: 'string', maxlength: 24, nullable: false, unique: false, references: 'members.id', cascadeDelete: true}, - tier_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'products.id'}, + tier_id: {type: 'string', maxlength: 24, nullable: false, unique: false, references: 'products.id'}, // These are null if type !== 'paid' cadence: { @@ -645,6 +657,7 @@ module.exports = { members_stripe_customers_subscriptions: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, customer_id: {type: 'string', maxlength: 255, nullable: false, unique: false, references: 'members_stripe_customers.customer_id', cascadeDelete: true}, + ghost_subscription_id: {type: 'string', maxlength: 24, nullable: true, references: 'subscriptions.id', constraintName: 'mscs_ghost_subscription_id_foreign', cascadeDelete: true}, subscription_id: {type: 'string', maxlength: 255, nullable: false, unique: true}, stripe_price_id: {type: 'string', maxlength: 255, nullable: false, unique: false, index: true, defaultTo: ''}, status: {type: 'string', maxlength: 50, nullable: false}, diff --git a/ghost/core/core/server/models/base/plugins/actions.js b/ghost/core/core/server/models/base/plugins/actions.js index 2eca8847258..d76285c8541 100644 --- a/ghost/core/core/server/models/base/plugins/actions.js +++ b/ghost/core/core/server/models/base/plugins/actions.js @@ -72,7 +72,7 @@ module.exports = function (Bookshelf) { * * We protect adding too many and uncontrolled events. * - * We could embedd adding actions more nicely in the future e.g. plugin. + * We could embed adding actions more nicely in the future e.g. plugin. */ addAction: (model, event, options) => { if (!model.wasChanged()) { diff --git a/ghost/core/core/server/models/email-recipient.js b/ghost/core/core/server/models/email-recipient.js index 894094923ef..46b9e594430 100644 --- a/ghost/core/core/server/models/email-recipient.js +++ b/ghost/core/core/server/models/email-recipient.js @@ -3,6 +3,20 @@ const ghostBookshelf = require('./base'); const EmailRecipient = ghostBookshelf.Model.extend({ tableName: 'email_recipients', hasTimestamps: false, + + filterRelations: function filterRelations() { + return { + email: { + // Mongo-knex doesn't support belongsTo relations + tableName: 'emails', + tableNameAs: 'email', + type: 'manyToMany', + joinTable: 'email_recipients', + joinFrom: 'id', + joinTo: 'email_id' + } + }; + }, email() { return this.belongsTo('Email', 'email_id'); diff --git a/ghost/core/core/server/models/email.js b/ghost/core/core/server/models/email.js index 0d49e2ccb05..a0788b8fe69 100644 --- a/ghost/core/core/server/models/email.js +++ b/ghost/core/core/server/models/email.js @@ -81,11 +81,7 @@ const Email = ghostBookshelf.Model.extend({ model.emitChange('deleted', options); } -}, { - post() { - return this.belongsTo('Post'); - } -}); +}, {}); const Emails = ghostBookshelf.Collection.extend({ model: Email diff --git a/ghost/core/core/server/models/member-click-event.js b/ghost/core/core/server/models/member-click-event.js index 7c86860ff8c..29250c625f5 100644 --- a/ghost/core/core/server/models/member-click-event.js +++ b/ghost/core/core/server/models/member-click-event.js @@ -10,7 +10,31 @@ const MemberClickEvent = ghostBookshelf.Model.extend({ member() { return this.belongsTo('Member', 'member_id', 'id'); + }, + + filterExpansions: function filterExpansions() { + const expansions = [{ + key: 'post_id', + replacement: 'link.post_id' + }]; + + return expansions; + }, + + filterRelations() { + return { + link: { + // Mongo-knex doesn't support belongsTo relations + tableName: 'redirects', + tableNameAs: 'link', + type: 'manyToMany', + joinTable: 'members_click_events', + joinFrom: 'id', + joinTo: 'redirect_id' + } + }; } + }, { async edit() { throw new errors.IncorrectUsageError({message: 'Cannot edit MemberClickEvent'}); diff --git a/ghost/core/core/server/models/member-paid-subscription-event.js b/ghost/core/core/server/models/member-paid-subscription-event.js index 7769d6823d1..d3315648123 100644 --- a/ghost/core/core/server/models/member-paid-subscription-event.js +++ b/ghost/core/core/server/models/member-paid-subscription-event.js @@ -25,6 +25,21 @@ const MemberPaidSubscriptionEvent = ghostBookshelf.Model.extend({ .groupByRaw('currency, DATE(created_at)') .orderByRaw('DATE(created_at)'); } + }, + + filterRelations() { + return { + subscriptionCreatedEvent: { + // Mongo-knex doesn't support belongsTo relations + tableName: 'members_subscription_created_events', + tableNameAs: 'subscriptionCreatedEvent', + type: 'manyToMany', + joinTable: 'members_paid_subscription_events', + joinFrom: 'id', + joinToForeign: 'subscription_id', + joinTo: 'subscription_id' + } + }; } }, { permittedOptions(methodName) { diff --git a/ghost/core/core/server/models/member.js b/ghost/core/core/server/models/member.js index 5e88a418cd0..741bc424d24 100644 --- a/ghost/core/core/server/models/member.js +++ b/ghost/core/core/server/models/member.js @@ -114,6 +114,12 @@ const Member = ghostBookshelf.Model.extend({ joinTable: 'email_recipients', joinFrom: 'member_id', joinTo: 'email_id' + }, + feedback: { + tableName: 'members_feedback', + tableNameAs: 'feedback', + type: 'oneToOne', + joinFrom: 'member_id' } }; }, diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 64bc9509cff..bcccf85ded0 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -236,6 +236,17 @@ Post = ghostBookshelf.Model.extend({ }, orderRawQuery: function orderRawQuery(field, direction, withRelated) { + if (field === 'sentiment') { + if (withRelated.includes('count.sentiment')) { + // Internally sentiment can be included via the count.sentiment relation. We can do a quick optimisation of the query in that case. + return { + orderByRaw: `count__sentiment ${direction}` + }; + } + return { + orderByRaw: `(select AVG(score) from \`members_feedback\` where posts.id = members_feedback.post_id) ${direction}` + }; + } if (field === 'email.open_rate' && withRelated && withRelated.indexOf('email') > -1) { return { // *1.0 is needed on one of the columns to prevent sqlite from @@ -1346,6 +1357,26 @@ Post = ghostBookshelf.Model.extend({ .as('count__paid_conversions'); }); }, + /** + * Combination of sigups and paid conversions, but unique per member + */ + conversions(modelOrCollection) { + modelOrCollection.query('columns', 'posts.*', (qb) => { + qb.count('*') + .from('k') + .with('k', (q) => { + q.select('member_id') + .from('members_subscription_created_events') + .whereRaw('posts.id = members_subscription_created_events.attribution_id') + .union(function () { + this.select('member_id') + .from('members_created_events') + .whereRaw('posts.id = members_created_events.attribution_id'); + }); + }) + .as('count__conversions'); + }); + }, clicks(modelOrCollection) { modelOrCollection.query('columns', 'posts.*', (qb) => { qb.countDistinct('members_click_events.member_id') @@ -1357,7 +1388,7 @@ Post = ghostBookshelf.Model.extend({ }, sentiment(modelOrCollection) { modelOrCollection.query('columns', 'posts.*', (qb) => { - qb.select(qb.client.raw('ROUND(AVG(score) * 100)')) + qb.select(qb.client.raw('COALESCE(ROUND(AVG(score) * 100), 0)')) .from('members_feedback') .whereRaw('posts.id = members_feedback.post_id') .as('count__sentiment'); @@ -1368,7 +1399,7 @@ Post = ghostBookshelf.Model.extend({ qb.count('*') .from('members_feedback') .whereRaw('posts.id = members_feedback.post_id AND members_feedback.score = 0') - .as('count__positive_feedback'); + .as('count__negative_feedback'); }); }, positive_feedback(modelOrCollection) { diff --git a/ghost/core/core/server/models/redirect.js b/ghost/core/core/server/models/redirect.js index 7a58e58278c..b857c3e1a3c 100644 --- a/ghost/core/core/server/models/redirect.js +++ b/ghost/core/core/server/models/redirect.js @@ -53,6 +53,7 @@ const Redirect = ghostBookshelf.Model.extend({ qb.countDistinct('members_click_events.member_id') .from('members_click_events') .whereRaw('redirects.id = members_click_events.redirect_id') + .whereRaw('redirects.updated_at <= members_click_events.created_at') .as('count__clicks'); }); } diff --git a/ghost/core/core/server/services/audience-feedback/index.js b/ghost/core/core/server/services/audience-feedback/index.js index a111db6e5a7..39ea79bc178 100644 --- a/ghost/core/core/server/services/audience-feedback/index.js +++ b/ghost/core/core/server/services/audience-feedback/index.js @@ -1,3 +1,5 @@ +const urlUtils = require('../../../shared/url-utils'); +const urlService = require('../../services/url'); const FeedbackRepository = require('./FeedbackRepository'); class AudienceFeedbackServiceWrapper { @@ -20,7 +22,12 @@ class AudienceFeedbackServiceWrapper { }); // Expose the service - this.service = new AudienceFeedbackService(); + this.service = new AudienceFeedbackService({ + urlService, + config: { + baseURL: new URL(urlUtils.urlFor('home', true)) + } + }); this.controller = new AudienceFeedbackController({repository: this.repository}); } } diff --git a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js index 88514e5a711..10f67433213 100644 --- a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js +++ b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js @@ -222,6 +222,10 @@ module.exports = { const startTime = Date.now(); debug(`sending message to ${recipients.length} recipients`); + // Update email content for this segment before searching replacements + emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment); + + // Check all the used replacements in this email const replacements = postEmailSerializer.parseReplacements(emailData); // collate static and dynamic data for each recipient ready for provider @@ -245,8 +249,6 @@ module.exports = { recipientData[recipient.member_email] = data; }); - emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment); - try { const response = await mailgunClient.send(emailData, recipientData, replacements); debug(`sent message (${Date.now() - startTime}ms)`); diff --git a/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js b/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js index 83597462097..008d34cc391 100644 --- a/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js +++ b/ghost/core/core/server/services/link-redirection/LinkRedirectRepository.js @@ -18,7 +18,7 @@ module.exports = class LinkRedirectRepository { } /** - * @param {InstanceType} linkRedirect + * @param {InstanceType} linkRedirect * @returns {Promise} */ async save(linkRedirect) { @@ -36,10 +36,14 @@ module.exports = class LinkRedirectRepository { } fromModel(model) { + // Store if link has been edited + const edited = model.get('created_at')?.getTime() !== model.get('updated_at')?.getTime(); + return new LinkRedirect({ id: model.id, from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)), - to: new URL(model.get('to')) + to: new URL(model.get('to')), + edited }); } @@ -55,10 +59,17 @@ module.exports = class LinkRedirectRepository { return result; } + async getFilteredIds(options) { + const linkRows = await this.#LinkRedirect.getFilteredCollectionQuery(options) + .select('redirects.id') + .distinct(); + return linkRows.map(row => row.id); + } + /** - * - * @param {URL} url - * @returns {Promise|undefined>} linkRedirect + * + * @param {URL} url + * @returns {Promise|undefined>} linkRedirect */ async getByURL(url) { // Strip subdirectory from path diff --git a/ghost/core/core/server/services/link-tracking/PostLinkRepository.js b/ghost/core/core/server/services/link-tracking/PostLinkRepository.js index 46c08ba93ad..844a10028d6 100644 --- a/ghost/core/core/server/services/link-tracking/PostLinkRepository.js +++ b/ghost/core/core/server/services/link-tracking/PostLinkRepository.js @@ -1,4 +1,5 @@ const {FullPostLink} = require('@tryghost/link-tracking'); +const _ = require('lodash'); /** * @typedef {import('bson-objectid').default} ObjectID @@ -22,8 +23,8 @@ module.exports = class PostLinkRepository { } /** - * - * @param {*} options + * + * @param {*} options * @returns {Promise[]>} */ async getAll(options) { @@ -48,6 +49,29 @@ module.exports = class PostLinkRepository { return result; } + async updateLinks(linkIds, updateData, options) { + const bulkUpdateOptions = _.pick(options, ['transacting']); + + const bulkActionResult = await this.#LinkRedirect.bulkEdit(linkIds, 'redirects', { + ...bulkUpdateOptions, + data: updateData + }); + + return { + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: bulkActionResult.successful, + unsuccessful: bulkActionResult.unsuccessful + }, + errors: bulkActionResult.errors, + unsuccessfulData: bulkActionResult.unsuccessfulData + } + } + }; + } + /** * @param {PostLink} postLink * @returns {Promise} diff --git a/ghost/core/core/server/services/link-tracking/index.js b/ghost/core/core/server/services/link-tracking/index.js index 7a72c5a87d8..a3608eaa9f1 100644 --- a/ghost/core/core/server/services/link-tracking/index.js +++ b/ghost/core/core/server/services/link-tracking/index.js @@ -1,6 +1,7 @@ const LinkClickRepository = require('./LinkClickRepository'); const PostLinkRepository = require('./PostLinkRepository'); const errors = require('@tryghost/errors'); +const urlUtils = require('../../../shared/url-utils'); class LinkTrackingServiceWrapper { async init() { @@ -38,7 +39,8 @@ class LinkTrackingServiceWrapper { linkRedirectService: linkRedirection.service, linkClickRepository: this.linkClickRepository, postLinkRepository, - DomainEvents + DomainEvents, + urlUtils }); await this.service.init(); diff --git a/ghost/core/core/server/services/mega/email-preview.js b/ghost/core/core/server/services/mega/email-preview.js index b13b7d83471..608008656ce 100644 --- a/ghost/core/core/server/services/mega/email-preview.js +++ b/ghost/core/core/server/services/mega/email-preview.js @@ -32,7 +32,7 @@ class EmailPreview { replacements.forEach((replacement) => { emailContent[replacement.format] = emailContent[replacement.format].replace( - replacement.match, + replacement.regexp, replacement.fallback || '' ); }); diff --git a/ghost/core/core/server/services/mega/feedback-buttons.js b/ghost/core/core/server/services/mega/feedback-buttons.js new file mode 100644 index 00000000000..763cc38631a --- /dev/null +++ b/ghost/core/core/server/services/mega/feedback-buttons.js @@ -0,0 +1,69 @@ +const {Color} = require('@tryghost/color-utils'); +const audienceFeedback = require('../audience-feedback'); + +const templateStrings = { + like: '%{feedback_button_like}%', + dislike: '%{feedback_button_dislike}%' +}; + +const generateLinks = (postId, uuid, html) => { + const positiveLink = audienceFeedback.service.buildLink( + uuid, + postId, + 1 + ); + const negativeLink = audienceFeedback.service.buildLink( + uuid, + postId, + 0 + ); + + html = html.replace(templateStrings.like, positiveLink.href); + html = html.replace(templateStrings.dislike, negativeLink.href); + + return html; +}; + +const getTemplate = (accentColor) => { + const likeButtonHtml = getButtonHtml(templateStrings.like, 'More like this', accentColor); + const dislikeButtonHtml = getButtonHtml(templateStrings.dislike, 'Less like this', accentColor); + + return (` + + +

What did you think of this post?

+ + + ${likeButtonHtml} + ${dislikeButtonHtml} + +
+ + + `); +}; + +function getButtonHtml(href, buttonText, accentColor) { + const color = new Color(accentColor); + const bgColor = `${accentColor}10`; + const textColor = color.darken(0.6).hex(); + + return (` + + + + + +
+ + ${buttonText} + +
+ + `); +} + +module.exports = { + generateLinks, + getTemplate +}; diff --git a/ghost/core/core/server/services/mega/post-email-serializer.js b/ghost/core/core/server/services/mega/post-email-serializer.js index 87d1d3ad525..a194e340b4f 100644 --- a/ghost/core/core/server/services/mega/post-email-serializer.js +++ b/ghost/core/core/server/services/mega/post-email-serializer.js @@ -16,11 +16,13 @@ const urlService = require('../../services/url'); const linkReplacer = require('@tryghost/link-replacer'); const linkTracking = require('../link-tracking'); const memberAttribution = require('../member-attribution'); +const feedbackButtons = require('./feedback-buttons'); +const labs = require('../../../shared/labs'); const ALLOWED_REPLACEMENTS = ['first_name', 'uuid']; const PostEmailSerializer = { - + // Format a full html document ready for email by inlining CSS, adjusting links, // and performing any client-specific fixes formatHtmlForEmail(html) { @@ -107,6 +109,19 @@ const PostEmailSerializer = { return signupUrl.href; }, + /** + * replaceFeedbackLinks + * + * Replace the button template links with real links + * + * @param {string} html + * @param {string} postId (will be url encoded) + * @param {string} memberUuid member uuid to use in the URL (will be url encoded) + */ + replaceFeedbackLinks(html, postId, memberUuid) { + return feedbackButtons.generateLinks(postId, memberUuid, html); + }, + // NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute async serializePostModel(model) { // fetch mobiledoc rather than html and plaintext so we can render email-specific contents @@ -163,12 +178,21 @@ const PostEmailSerializer = { const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g; const REPLACEMENT_STRING_REGEX = /\{(?\w*?)(?:,? *(?:"|")(?.*?)(?:"|"))?\}/; + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + const replacements = []; ['html', 'plaintext'].forEach((format) => { let result; while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) { const [replacementMatch, replacementStr] = result; + + // Did we already found this match and added it to the replacements array? + if (replacements.find(r => r.match === replacementMatch && r.format === format)) { + continue; + } const match = replacementStr.match(REPLACEMENT_STRING_REGEX); if (match) { @@ -181,6 +205,7 @@ const PostEmailSerializer = { format, id, match: replacementMatch, + regexp: new RegExp(escapeRegExp(replacementMatch), 'g'), recipientProperty: `member_${recipientProperty}`, fallback }); @@ -206,6 +231,7 @@ const PostEmailSerializer = { titleAlignment: newsletter.get('title_alignment'), bodyFontCategory: newsletter.get('body_font_category'), showBadge: newsletter.get('show_badge'), + feedbackEnabled: newsletter.get('feedback_enabled') && labs.isSet('audienceFeedback'), footerContent: newsletter.get('footer_content'), showHeaderName: newsletter.get('show_header_name'), accentColor, @@ -335,7 +361,7 @@ const PostEmailSerializer = { plaintext: post.plaintext }; - /** + /** * If a part of the email is members-only and the post is paid-only, add a paywall: * - Just before sending the email, we'll hide the paywall or paid content depending on the member segment it is sent to. * - We already need to do URL-replacement on the HTML here @@ -369,13 +395,21 @@ const PostEmailSerializer = { // Add link click tracking url = await linkTracking.service.addTrackingToUrl(url, post, '--uuid--'); - + // We need to convert to a string at this point, because we need invalid string characters in the URL const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%'); return str; }); } + // Add buttons + if (labs.isSet('audienceFeedback')) { + // create unique urls for every recipient (for example, for feedback buttons) + // Note, we need to use a different member uuid in the links because `%%{uuid}%%` would get escaped by the URL object when set as a search param + const urlSafeToken = '--' + new Date().getTime() + 'url-safe-uuid--'; + result.html = this.replaceFeedbackLinks(result.html, post.id, urlSafeToken).replace(new RegExp(urlSafeToken, 'g'), '%%{uuid}%%'); + } + // Clean up any unknown replacements strings to get our final content const {html, plaintext} = this.normalizeReplacementStrings(result); const data = { @@ -490,7 +524,7 @@ const PostEmailSerializer = { }); result.html = this.formatHtmlForEmail($.html()); - result.plaintext = htmlToPlaintext.email(result.html); + result.plaintext = htmlToPlaintext.email(result.html); delete result.post; return result; diff --git a/ghost/core/core/server/services/mega/template.js b/ghost/core/core/server/services/mega/template.js index 42d6635e26d..d67bcc15d59 100644 --- a/ghost/core/core/server/services/mega/template.js +++ b/ghost/core/core/server/services/mega/template.js @@ -1,4 +1,5 @@ const {escapeHtml: escape} = require('@tryghost/string'); +const feedbackButtons = require('./feedback-buttons'); /* eslint indent: warn, no-irregular-whitespace: warn */ const iff = (cond, yes, no) => (cond ? yes : no); @@ -1265,6 +1266,8 @@ ${ templateSettings.showBadge ? ` + ${iff(templateSettings.feedbackEnabled, feedbackButtons.getTemplate(templateSettings.accentColor), '')} + diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 944999141bf..f3e1d01a9d5 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -194,7 +194,8 @@ function createApiInstance(config) { StripePrice: models.StripePrice, Product: models.Product, Settings: models.Settings, - Comment: models.Comment + Comment: models.Comment, + MemberFeedback: models.MemberFeedback }, stripeAPIService: stripeService.api, offersAPI: offersService.api, diff --git a/ghost/core/core/server/services/newsletters/index.js b/ghost/core/core/server/services/newsletters/index.js index 9f09b22576f..e9315c95619 100644 --- a/ghost/core/core/server/services/newsletters/index.js +++ b/ghost/core/core/server/services/newsletters/index.js @@ -4,6 +4,7 @@ const mail = require('../mail'); const models = require('../../models'); const urlUtils = require('../../../shared/url-utils'); const limitService = require('../limits'); +const labs = require('../../../shared/labs'); const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000; @@ -13,5 +14,6 @@ module.exports = new NewslettersService({ mail, singleUseTokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY), urlUtils, - limitService + limitService, + labs }); diff --git a/ghost/core/core/server/services/newsletters/service.js b/ghost/core/core/server/services/newsletters/service.js index 0d0bc89436f..0ffaf5a17eb 100644 --- a/ghost/core/core/server/services/newsletters/service.js +++ b/ghost/core/core/server/services/newsletters/service.js @@ -21,13 +21,16 @@ class NewslettersService { * @param {Object} options.singleUseTokenProvider * @param {Object} options.urlUtils * @param {ILimitService} options.limitService + * @param {Object} options.labs */ - constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService}) { + constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService, labs}) { this.NewsletterModel = NewsletterModel; this.MemberModel = MemberModel; this.urlUtils = urlUtils; /** @private */ this.limitService = limitService; + /** @private */ + this.labs = labs; /* email verification setup */ @@ -251,6 +254,13 @@ class NewslettersService { } } + if (cleanedAttrs.feedback_enabled) { + if (!this.labs.isSet('audienceFeedback')) { + // Not allowed to set to true + cleanedAttrs.feedback_enabled = false; + } + } + return {cleanedAttrs, emailsToVerify}; } diff --git a/ghost/core/core/server/services/url/UrlGenerator.js b/ghost/core/core/server/services/url/UrlGenerator.js index fc60f7097df..8d95dc86c05 100644 --- a/ghost/core/core/server/services/url/UrlGenerator.js +++ b/ghost/core/core/server/services/url/UrlGenerator.js @@ -135,7 +135,9 @@ class UrlGenerator { /** * @description Listener which get's called when a resource was added on runtime. - * @param {String} event + * @param {Object} event + * @param {String} event.type + * @param {String} event.id * @private */ _onAdded(event) { @@ -188,7 +190,7 @@ class UrlGenerator { } /** - * @description Generate url based on the permlink configuration of the target router. + * @description Generate url based on the permalink configuration of the target router. * * @NOTE We currently generate relative urls (https://github.com/TryGhost/Ghost/commit/7b0d5d465ba41073db0c3c72006da625fa11df32). */ diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 038d6950752..ea5cf098928 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -310,6 +310,7 @@ module.exports = function apiRoutes() { router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit)); router.get('/links', mw.authAdminApi, http(api.links.browse)); + router.put('/links/bulk', mw.authAdminApi, http(api.links.bulkEdit)); return router; }; diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 1afb191c5cd..f0cf01b154f 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -158,7 +158,7 @@ }, "portal": { "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js", - "version": "2.14" + "version": "2.18" }, "sodoSearch": { "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js", diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 7464068269a..02ce9685a26 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -19,7 +19,8 @@ const GA_FEATURES = [ 'freeTrial', 'compExpiring', 'searchHelper', - 'emailAlerts' + 'emailAlerts', + 'fixNewsletterLinks' ]; // NOTE: this allowlist is meant to be used to filter out any unexpected @@ -35,8 +36,7 @@ const ALPHA_FEATURES = [ 'beforeAfterCard', 'lexicalEditor', 'exploreApp', - 'audienceFeedback', - 'fixNewsletterLinks' + 'audienceFeedback' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/package.json b/ghost/core/package.json index 8f1e5f6bd6e..768b74c4479 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.18.0", + "version": "5.20.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -21,7 +21,7 @@ "license": "MIT", "scripts": { "start": "node index", - "setup": "knex-migrator init && grunt symlink || (exit 0)", + "setup": "knex-migrator init", "build": "npm pack --pack-destination ../..", "test": "yarn test:unit", "test:single": "mocha --require=./test/utils/overrides.js --exit --trace-warnings --recursive --extension=test.js --timeout=60000", @@ -59,14 +59,14 @@ "@tryghost/api-framework": "0.0.0", "@tryghost/api-version-compatibility-service": "0.0.0", "@tryghost/audience-feedback": "0.0.0", - "@tryghost/bookshelf-plugins": "0.5.2", + "@tryghost/bookshelf-plugins": "0.5.4", "@tryghost/bootstrap-socket": "0.0.0", "@tryghost/color-utils": "0.1.21", "@tryghost/config-url-helpers": "1.0.3", "@tryghost/constants": "0.0.0", "@tryghost/custom-theme-settings-service": "0.0.0", - "@tryghost/database-info": "0.3.11", - "@tryghost/debug": "0.1.18", + "@tryghost/database-info": "0.3.12", + "@tryghost/debug": "0.1.19", "@tryghost/domain-events": "0.0.0", "@tryghost/email-analytics-provider-mailgun": "0.0.0", "@tryghost/email-analytics-service": "0.0.0", @@ -78,11 +78,11 @@ "@tryghost/http-cache-utils": "0.1.4", "@tryghost/image-transform": "1.2.2", "@tryghost/job-manager": "0.0.0", - "@tryghost/kg-card-factory": "3.1.5", + "@tryghost/kg-card-factory": "3.1.7", "@tryghost/kg-default-atoms": "3.1.4", - "@tryghost/kg-default-cards": "5.18.3", - "@tryghost/kg-lexical-html-renderer": "0.0.8", - "@tryghost/kg-mobiledoc-html-renderer": "5.3.7", + "@tryghost/kg-default-cards": "5.18.5", + "@tryghost/kg-lexical-html-renderer": "0.0.10", + "@tryghost/kg-mobiledoc-html-renderer": "5.3.9", "@tryghost/limit-service": "1.2.3", "@tryghost/link-redirects": "0.0.0", "@tryghost/link-replacer": "0.0.0", @@ -120,7 +120,7 @@ "@tryghost/staff-service": "0.0.0", "@tryghost/stats-service": "0.0.0", "@tryghost/string": "0.2.1", - "@tryghost/tpl": "0.1.18", + "@tryghost/tpl": "0.1.19", "@tryghost/update-check-service": "0.0.0", "@tryghost/url-utils": "4.2.0", "@tryghost/validator": "0.1.29", @@ -185,22 +185,22 @@ "xml": "1.0.1" }, "optionalDependencies": { - "@tryghost/html-to-mobiledoc": "1.8.16", + "@tryghost/html-to-mobiledoc": "1.8.19", "sqlite3": "5.1.2" }, "devDependencies": { "@playwright/test": "1.27.1", "@tryghost/express-test": "0.11.5", - "@tryghost/webhook-mock-receiver": "0.2.0", + "@tryghost/webhook-mock-receiver": "0.2.1", "@types/common-tags": "1.8.1", "c8": "7.12.0", "cli-progress": "3.11.2", "cssnano": "5.1.13", - "eslint": "8.25.0", - "html-validate": "7.6.0", - "inquirer": "8.2.4", + "eslint": "8.26.0", + "html-validate": "7.7.0", + "inquirer": "8.2.5", "jwks-rsa": "2.1.5", - "mocha": "10.0.0", + "mocha": "10.1.0", "mocha-slow-test-reporter": "0.1.2", "mock-knex": "TryGhost/mock-knex#8ecb8c227bf463c991c3d820d33f59efc3ab9682", "nock": "13.2.9", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap new file mode 100644 index 00000000000..7d1bf8e8cab --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -0,0 +1,750 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Activity Feed API Can filter events by post id 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": "20", + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 15, + }, + }, +} +`; + +exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "23031", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Can limit events 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": "2", + "next": null, + "page": null, + "pages": 8, + "prev": null, + "total": 15, + }, + }, +} +`; + +exports[`Activity Feed API Can limit events 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1240", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns click events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/0", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "member1@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Mr Egg", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "HTML Ipsum", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/1", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "member2@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": null, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Ghostly Kitchen Sink", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/2", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Egon Spengler", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Short and Sweet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/3", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "trialing@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Ray Stantz", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not finished yet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/4", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "comped@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Vinz Clortho", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not so short, bit complex", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/5", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "vip@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Winston Zeddemore", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/6", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "vip-paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Peter Venkman", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a draft static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "link": Object { + "from": "/r/7", + "to": "https:://ghost.org", + }, + "member": Object { + "avatar_image": null, + "email": "with-product@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Dana Barrett", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a scheduled post!!", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + }, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, +} +`; + +exports[`Activity Feed API Returns click events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "3722", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns comments in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 2, + }, + }, +} +`; + +exports[`Activity Feed API Returns comments in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1238", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns email delivered events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 1, + }, + }, +} +`; + +exports[`Activity Feed API Returns email delivered events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1246", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns email opened events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 1, + }, + }, +} +`; + +exports[`Activity Feed API Returns email opened events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "1243", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns email sent events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 5, + }, + }, +} +`; + +exports[`Activity Feed API Returns email sent events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "5774", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns feedback events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "member1@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Mr Egg", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "HTML Ipsum", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "member2@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": null, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Ghostly Kitchen Sink", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Egon Spengler", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Short and Sweet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "trialing@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Ray Stantz", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not finished yet", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "comped@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Vinz Clortho", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "Not so short, bit complex", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "vip@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Winston Zeddemore", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "vip-paid@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Peter Venkman", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a draft static page", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + Object { + "data": Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "member": Object { + "avatar_image": null, + "email": "with-product@test.com", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Dana Barrett", + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "post": Object { + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "title": "This is a scheduled post!!", + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "score": Any, + }, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, +} +`; + +exports[`Activity Feed API Returns feedback events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "3690", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Activity Feed API Returns signup events in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, +} +`; + +exports[`Activity Feed API Returns signup events in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "23027", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index c8989d40bb2..677ab84a1ba 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -416,6 +416,8 @@ table.body figcaption a { + +
@@ -468,7 +470,7 @@ exports[`Email Preview API Read can read post email preview with email card and Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18188", + "content-length": "18216", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -806,6 +808,8 @@ table.body figcaption a { + +
@@ -870,7 +874,7 @@ exports[`Email Preview API Read can read post email preview with fields 2: [head Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "23013", + "content-length": "23041", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1234,6 +1238,8 @@ table.body figcaption a { + +
@@ -1280,7 +1286,7 @@ exports[`Email Preview API Read has custom content transformations for email com Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "17950", + "content-length": "17978", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1618,6 +1624,8 @@ table.body figcaption a { + +
@@ -1664,7 +1672,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18316", + "content-length": "18344", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2388,6 +2396,8 @@ table.body figcaption a { + +
@@ -2434,7 +2444,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 2: [headers Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18316", + "content-length": "18344", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap new file mode 100644 index 00000000000..13cbb6dae69 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/links.test.js.snap @@ -0,0 +1,572 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Links API Can browse all links 1: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can browse all links 2: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can browse all links 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "930", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can browse all links 3: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "885", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update links with external redirect 1: [body] 1`] = ` +Object { + "bulk": Object { + "action": "updateLink", + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 1, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Links API Can bulk update links with external redirect 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "117", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update links with external redirect 3: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": true, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "https://example.com/subscribe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update links with external redirect 4: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "https://example.com/subscribe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update links with external redirect 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "929", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update links with external redirect 5: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "885", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 1: [body] 1`] = ` +Object { + "bulk": Object { + "action": "updateLink", + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 2, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "117", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 3: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": true, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": true, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 4: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "884", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can bulk update multiple links with same site redirect 5: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "841", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can call bulk update link with 0 matches 1: [body] 1`] = ` +Object { + "bulk": Object { + "action": "updateLink", + "meta": Object { + "errors": Array [], + "stats": Object { + "successful": 0, + "unsuccessful": 0, + }, + "unsuccessfulData": Array [], + }, + }, +} +`; + +exports[`Links API Can call bulk update link with 0 matches 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "117", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can call bulk update link with 0 matches 3: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": "https://example.com/subscripe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "edited": false, + "from": Any, + "link_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can call bulk update link with 0 matches 4: [body] 1`] = ` +Object { + "links": Array [ + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": "https://example.com/subscripe?ref=Test-newsletter", + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + Object { + "count": Object { + "clicks": Any, + }, + "link": Object { + "from": Any, + "link_id": Any, + "to": Any, + }, + "post_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + }, + ], + "meta": Object { + "pagination": Object { + "page": 1, + "pages": 1, + "total": 3, + }, + }, +} +`; + +exports[`Links API Can call bulk update link with 0 matches 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "930", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Links API Can call bulk update link with 0 matches 5: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "885", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap index 25e7262a360..253fcf6d495 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members-exporter.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Members API — exportCSV Can export a member without products 1: [headers] 1`] = ` +exports[`Members API — exportCSV Can export a member without tiers 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -13,7 +13,7 @@ Object { } `; -exports[`Members API — exportCSV Can export a member without products 2: [headers] 1`] = ` +exports[`Members API — exportCSV Can export a member without tiers 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -26,7 +26,7 @@ Object { } `; -exports[`Members API — exportCSV Can export a member without products 3: [headers] 1`] = ` +exports[`Members API — exportCSV Can export a member without tiers 3: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -234,7 +234,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 1: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -247,7 +247,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 2: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -260,7 +260,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 3: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 3: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", @@ -273,7 +273,7 @@ Object { } `; -exports[`Members API — exportCSV Can export products 4: [headers] 1`] = ` +exports[`Members API — exportCSV Can export tiers 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap index 8f875f54553..75bd11156e7 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members-newsletters.test.js.snap @@ -63,7 +63,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2088", + "content-length": "2145", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -218,7 +218,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11872", + "content-length": "12043", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -289,7 +289,7 @@ exports[`Members API - With Newsletters Can fetch members who are NOT subscribed Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2088", + "content-length": "2145", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -444,7 +444,7 @@ exports[`Members API - With Newsletters Can fetch members who are subscribed 2: Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "11872", + "content-length": "12043", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index dac852d6e5a..9d56a0e19ee 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -274,6 +274,12 @@ Object { "type": Any, }, ], + "meta": Object { + "pagination": Object { + "limit": 10, + "total": 5, + }, + }, } `; @@ -281,7 +287,56 @@ exports[`Members API - member attribution Returns sign up attributions in activi Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "9204", + "content-length": "9249", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Members API - member attribution Returns sign up attributions of all types in activity feed 1: [body] 1`] = ` +Object { + "events": Array [ + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + Object { + "data": Any, + "type": Any, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 5, + }, + }, +} +`; + +exports[`Members API - member attribution Returns sign up attributions of all types in activity feed 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "9295", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1307,9 +1362,11 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", "slug": "default-product", @@ -1318,6 +1375,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, "yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, ], @@ -1332,7 +1390,7 @@ exports[`Members API Can add complimentary subscription (out of date) 4: [header Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2923", + "content-length": "3037", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1529,7 +1587,7 @@ exports[`Members API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "13859", + "content-length": "14087", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -1699,9 +1757,11 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", "slug": "default-product", @@ -1710,6 +1770,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, "yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, ], @@ -1724,7 +1785,7 @@ exports[`Members API Can create a member with an existing complimentary subscrip Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2974", + "content-length": "3088", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1803,7 +1864,7 @@ exports[`Members API Can create a member with an existing paid subscription 2: [ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2960", + "content-length": "3074", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -1874,9 +1935,11 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "expiry_at": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "name": "Default Product", "slug": "default-product", @@ -1885,6 +1948,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": "/welcome-paid", + "yearly_price": 5000, "yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, ], @@ -1899,7 +1963,7 @@ exports[`Members API Can create a new member with a product (complimentary) 2: [ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2621", + "content-length": "2735", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -2324,7 +2388,7 @@ Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", "content-disposition": Any, - "content-length": "215", + "content-length": "212", "content-type": "text/csv; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2485,7 +2549,7 @@ exports[`Members API Can filter by paid status 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "10103", + "content-length": "10331", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2615,7 +2679,7 @@ exports[`Members API Can filter by signup attribution 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4417", + "content-length": "4474", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2790,7 +2854,7 @@ exports[`Members API Can filter on newsletter slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8826", + "content-length": "8940", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -2995,7 +3059,7 @@ exports[`Members API Can filter on tier slug 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "21167", + "content-length": "22079", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -3179,7 +3243,7 @@ exports[`Members API Can ignore any unknown includes 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "10103", + "content-length": "10331", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -3993,7 +4057,7 @@ exports[`Members API Can subscribe to a newsletter 5: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "5053", + "content-length": "5144", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -4650,7 +4714,7 @@ exports[`Members API Search for paid members retrieves member with email paid@te Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2528", + "content-length": "2585", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap index 2e2c460fb73..f07b2ea5a61 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap @@ -21,10 +21,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -50,6 +49,7 @@ Object { "primary_author": Any, "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "sentiment": 0, "slug": "scheduled-post", "status": "scheduled", "tags": Any, @@ -71,10 +71,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -102,6 +101,7 @@ Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac tu "primary_author": Any, "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "sentiment": 0, "slug": "unfinished", "status": "draft", "tags": Any, @@ -123,7 +123,7 @@ exports[`Posts API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "9998", + "content-length": "10236", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -152,10 +152,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -185,6 +184,7 @@ Object { "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "reading_time": 0, + "sentiment": 0, "slug": "scheduled-post", "status": "scheduled", "tags": Any, @@ -206,10 +206,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -259,6 +258,7 @@ Header Level 3 "primary_tag": Any, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "reading_time": 1, + "sentiment": 0, "slug": "unfinished", "status": "draft", "tags": Any, @@ -280,7 +280,7 @@ exports[`Posts API Can browse with formats 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "12864", + "content-length": "13102", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -299,10 +299,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -331,6 +330,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "lexical-test", "status": "draft", "tags": Any, @@ -352,7 +352,7 @@ exports[`Posts API Create Can create a post with lexical 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3795", + "content-length": "3914", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -372,10 +372,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -404,6 +403,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "mobiledoc-test", "status": "draft", "tags": Any, @@ -425,7 +425,7 @@ exports[`Posts API Create Can create a post with mobiledoc 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3611", + "content-length": "3730", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -546,10 +546,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -578,6 +577,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "lexical-update-test", "status": "draft", "tags": Any, @@ -599,7 +599,7 @@ exports[`Posts API Update Can update a post with lexical 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3746", + "content-length": "3865", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -619,10 +619,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -651,6 +650,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "lexical-update-test", "status": "draft", "tags": Any, @@ -672,7 +672,7 @@ exports[`Posts API Update Can update a post with lexical 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3743", + "content-length": "3862", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -692,10 +692,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -724,6 +723,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "mobiledoc-update-test", "status": "draft", "tags": Any, @@ -745,7 +745,7 @@ exports[`Posts API Update Can update a post with mobiledoc 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3556", + "content-length": "3675", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, @@ -765,10 +765,9 @@ Object { "comment_id": Any, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, - "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -797,6 +796,7 @@ Object { "primary_tag": Any, "published_at": null, "reading_time": 0, + "sentiment": 0, "slug": "mobiledoc-update-test", "status": "draft", "tags": Any, @@ -818,7 +818,7 @@ exports[`Posts API Update Can update a post with mobiledoc 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3553", + "content-length": "3672", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index 65ba2ea2fa1..e1393b18984 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -627,7 +627,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3450", + "content-length": "3478", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap index 6c22af9e006..d447c70c8ac 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap @@ -96,8 +96,8 @@ Object { "count": 1, "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, "negative_delta": 0, - "positive_delta": 1, - "signups": 1, + "positive_delta": 4, + "signups": 4, "tier": StringMatching /\\[a-f0-9\\]\\{24\\}/, }, Object { diff --git a/ghost/core/test/e2e-api/admin/activity-feed.test.js b/ghost/core/test/e2e-api/admin/activity-feed.test.js new file mode 100644 index 00000000000..282b97686a2 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/activity-feed.test.js @@ -0,0 +1,243 @@ +const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework'); +const {anyEtag, anyObjectId, anyUuid, anyISODate, anyString, anyObject, anyNumber} = matchers; +const models = require('../../../core/server/models'); + +const assert = require('assert'); + +let agent; +describe('Activity Feed API', function () { + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments', 'redirects', 'clicks', 'feedback', 'members:emails'); + await agent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockStripe(); + mockManager.mockMail(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + // Activity feed + it('Returns comments in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:comment_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(2).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'comment_event'), 'Expected a comment event'); + assert(!body.events.find(e => e.type !== 'comment_event'), 'Expected only comment events'); + }); + }); + + it('Returns click events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:click_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(8).fill({ + type: anyString, + data: { + created_at: anyISODate, + member: { + id: anyObjectId, + uuid: anyUuid + }, + post: { + id: anyObjectId, + uuid: anyUuid, + url: anyString + } + } + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'click_event'), 'Expected a click event'); + assert(!body.events.find(e => e.type !== 'click_event'), 'Expected only click events'); + }); + }); + + it('Returns feedback events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:feedback_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(8).fill({ + type: anyString, + data: { + created_at: anyISODate, + id: anyObjectId, + member: { + id: anyObjectId, + uuid: anyUuid + }, + post: { + id: anyObjectId, + uuid: anyUuid, + url: anyString + }, + score: anyNumber + } + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'feedback_event'), 'Expected a feedback event'); + assert(!body.events.find(e => e.type !== 'feedback_event'), 'Expected only feedback events'); + }); + }); + + it('Returns signup events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:signup_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(8).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'signup_event'), 'Expected a signup event'); + assert(!body.events.find(e => e.type !== 'signup_event'), 'Expected only signup events'); + }); + }); + + it('Returns email sent events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_sent_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(5).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_sent_event'), 'Expected an email sent event'); + assert(!body.events.find(e => e.type !== 'email_sent_event'), 'Expected only email sent events'); + }); + }); + + it('Returns email delivered events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_delivered_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(1).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_delivered_event'), 'Expected an email delivered event'); + assert(!body.events.find(e => e.type !== 'email_delivered_event'), 'Expected only email delivered events'); + }); + }); + + it('Returns email opened events in activity feed', async function () { + // Check activity feed + await agent + .get(`/members/events?filter=type:email_opened_event`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(1).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event'); + assert(!body.events.find(e => e.type !== 'email_opened_event'), 'Expected only email opened events'); + }); + }); + + it('Can filter events by post id', async function () { + const postId = fixtureManager.get('posts', 0).id; + + await agent + .get(`/members/events?filter=data.post_id:${postId}&limit=20`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(15).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post'); + + // Check all post_id event types are covered by this test + assert(body.events.find(e => e.type === 'click_event'), 'Expected a click event'); + assert(body.events.find(e => e.type === 'comment_event'), 'Expected a comment event'); + assert(body.events.find(e => e.type === 'feedback_event'), 'Expected a feedback event'); + assert(body.events.find(e => e.type === 'signup_event'), 'Expected a signup event'); + assert(body.events.find(e => e.type === 'subscription_event'), 'Expected a subscription event'); + assert(body.events.find(e => e.type === 'email_delivered_event'), 'Expected an email delivered event'); + assert(body.events.find(e => e.type === 'email_sent_event'), 'Expected an email sent event'); + assert(body.events.find(e => e.type === 'email_opened_event'), 'Expected an email opened event'); + + // Assert total is correct + assert.equal(body.meta.pagination.total, 15); + }); + }); + + it('Can limit events', async function () { + const postId = fixtureManager.get('posts', 0).id; + await agent + .get(`/members/events?filter=data.post_id:${postId}&limit=2`) + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + events: new Array(2).fill({ + type: anyString, + data: anyObject + }) + }) + .expect(({body}) => { + assert(!body.events.find(e => (e.data?.post?.id ?? e.data?.attribution?.id ?? e.data?.email?.post_id) !== postId), 'Should only return events for the post'); + + // Assert total is correct + assert.equal(body.meta.pagination.total, 15); + }); + }); +}); diff --git a/ghost/core/test/e2e-api/admin/links.test.js b/ghost/core/test/e2e-api/admin/links.test.js new file mode 100644 index 00000000000..6f4f325c099 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/links.test.js @@ -0,0 +1,228 @@ +const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); +const {anyObjectId, anyString, anyEtag, anyNumber} = matchers; + +const matchLink = { + post_id: anyObjectId, + link: { + link_id: anyObjectId, + from: anyString, + to: anyString, + edited: false + }, + count: { + clicks: anyNumber + } +}; + +async function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +describe('Links API', function () { + let agent; + beforeEach(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts', 'links'); + await agent.loginAsOwner(); + }); + + it('Can browse all links', async function () { + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: new Array(3).fill(matchLink) + }); + }); + + it('Can bulk update multiple links with same site redirect', async function () { + const req = await agent.get('links'); + const siteLink = req.body.links.find((link) => { + return link.link.to.includes('/email/'); + }); + const postId = siteLink.post_id; + const originalTo = siteLink.link.to; + const filter = `post_id:${postId}+to:'${originalTo}'`; + // Sleep ensures the updated time of the link is different than created + await sleep(1000); + await agent + .put(`links/bulk/?filter=${encodeURIComponent(filter)}`) + .body({ + bulk: { + action: 'updateLink', + meta: { + link: { + to: 'http://127.0.0.1:2369/blog/emails/test?example=1' + } + } + } + }) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: 2, + unsuccessful: 0 + }, + errors: [], + unsuccessfulData: [] + } + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: [ + matchLink, + { + ...matchLink, + link: { + ...matchLink.link, + to: 'http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df', + edited: true + } + }, + { + ...matchLink, + link: { + ...matchLink.link, + to: 'http://127.0.0.1:2369/blog/emails/test?example=1&ref=Test-newsletter&attribution_type=post&attribution_id=618ba1ffbe2896088840a6df', + edited: true + } + } + ] + }); + }); + + it('Can bulk update links with external redirect', async function () { + const req = await agent.get('links'); + const siteLink = req.body.links.find((link) => { + return link.link.to.includes('subscripe'); + }); + const postId = siteLink.post_id; + const originalTo = siteLink.link.to; + const filter = `post_id:${postId}+to:'${originalTo}'`; + await sleep(1000); + await agent + .put(`links/bulk/?filter=${encodeURIComponent(filter)}`) + .body({ + bulk: { + action: 'updateLink', + meta: { + link: { + to: 'https://example.com/subscribe?ref=Test-newsletter' + } + } + } + }) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: 1, + unsuccessful: 0 + }, + errors: [], + unsuccessfulData: [] + } + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: [ + { + ...matchLink, + link: { + ...matchLink.link, + to: 'https://example.com/subscribe?ref=Test-newsletter', + edited: true + } + }, + matchLink, + matchLink + ] + }); + }); + + it('Can call bulk update link with 0 matches', async function () { + const req = await agent.get('links'); + const siteLink = req.body.links.find((link) => { + return link.link.to.includes('subscripe'); + }); + const postId = siteLink.post_id; + const originalTo = 'https://empty.example.com'; + const filter = `post_id:${postId}+to:'${originalTo}'`; + await agent + .put(`links/bulk/?filter=${encodeURIComponent(filter)}`) + .body({ + bulk: { + action: 'updateLink', + meta: { + link: { + to: 'https://example.com/subscribe?ref=Test-newsletter' + } + } + } + }) + .expectStatus(200) + .matchBodySnapshot({ + bulk: { + action: 'updateLink', + meta: { + stats: { + successful: 0, + unsuccessful: 0 + }, + errors: [], + unsuccessfulData: [] + } + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + await agent + .get('links') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + links: [ + { + ...matchLink, + link: { + ...matchLink.link, + to: 'https://example.com/subscripe?ref=Test-newsletter' + } + }, + matchLink, + matchLink + ] + }); + }); +}); diff --git a/ghost/core/test/e2e-api/admin/members-exporter.test.js b/ghost/core/test/e2e-api/admin/members-exporter.test.js index ecd112d04c5..7902a54aa60 100644 --- a/ghost/core/test/e2e-api/admin/members-exporter.test.js +++ b/ghost/core/test/e2e-api/admin/members-exporter.test.js @@ -51,7 +51,7 @@ async function testOutput(member, asserts, filters = []) { 'content-disposition': anyString }); - res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/); + res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/); let csv = Papa.parse(res.text, {header: true}); let row = csv.data.find(r => r.id === member.id); @@ -72,8 +72,8 @@ describe('Members API — exportCSV', function () { await agent.loginAsOwner(); await models.Product.add({ - name: 'Extra Paid Product', - slug: 'extra-product', + name: 'Extra Paid Tier', + slug: 'extra-tier', type: 'paid', active: true, visibility: 'public' @@ -106,8 +106,8 @@ describe('Members API — exportCSV', function () { mockManager.restore(); }); - it('Can export products', async function () { - // Create a new member with a product + it('Can export tiers', async function () { + // Create a new member with a product (to be renamed to "tiers" once the changes is done on model layer) const member = await createMember({ name: 'Test member', products: tiers @@ -119,11 +119,11 @@ describe('Members API — exportCSV', function () { basicAsserts(member, row); should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); - should(row.products.split(',').sort().join(',')).eql(tiersList); - }, [`filter=products:${tiers[0].get('slug')}`, 'filter=subscribed:false']); + should(row.tiers.split(',').sort().join(',')).eql(tiersList); + }, [`filter=tier:[${tiers[0].get('slug')}]`, 'filter=subscribed:false']); }); - it('Can export a member without products', async function () { + it('Can export a member without tiers', async function () { // Create a new member with a product const member = await createMember({ name: 'Test member 2', @@ -134,7 +134,7 @@ describe('Members API — exportCSV', function () { basicAsserts(member, row); should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); }, ['filter=subscribed:false']); }); @@ -157,7 +157,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); should(row.labels).eql(labelsList); - should(row.products).eql(''); + should(row.tiers).eql(''); }, [`filter=label:${labels[0].get('slug')}`, 'filter=subscribed:false']); }); @@ -174,7 +174,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql('true'); should(row.labels).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); }, ['filter=status:comped', 'filter=subscribed:false']); }); @@ -193,7 +193,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('true'); should(row.complimentary_plan).eql(''); should(row.labels).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); }, ['filter=subscribed:true']); }); @@ -232,7 +232,7 @@ describe('Members API — exportCSV', function () { should(row.subscribed_to_emails).eql('false'); should(row.complimentary_plan).eql(''); should(row.labels).eql(''); - should(row.products).eql(''); + should(row.tiers).eql(''); should(row.stripe_customer_id).eql('cus_12345'); }, ['filter=subscribed:false', 'filter=subscriptions.subscription_id:sub_123']); }); diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index 4a6cae97664..0d2ae0c082e 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -390,7 +390,7 @@ describe('Members API - member attribution', function () { }); // Activity feed - it('Returns sign up attributions in activity feed', async function () { + it('Returns sign up attributions of all types in activity feed', async function () { // Check activity feed await agent .get(`/members/events/?filter=type:signup_event`) @@ -431,56 +431,6 @@ describe('Members API', function () { mockManager.restore(); }); - // Activity feed - it('Returns comments in activity feed', async function () { - // Check activity feed - await agent - .get(`/members/events?filter=type:comment_event`) - .expectStatus(200) - .matchHeaderSnapshot({ - etag: anyEtag - }) - .matchBodySnapshot({ - events: new Array(2).fill({ - type: anyString, - data: anyObject - }) - }) - .expect(({body}) => { - should(body.events.find(e => e.type === 'comment_event')).not.be.undefined(); - }); - }); - - it('Returns click events in activity feed', async function () { - // Check activity feed - await agent - .get(`/members/events?filter=type:click_event`) - .expectStatus(200) - .matchHeaderSnapshot({ - etag: anyEtag - }) - .matchBodySnapshot({ - events: new Array(8).fill({ - type: anyString, - data: { - created_at: anyISODate, - member: { - id: anyObjectId, - uuid: anyUuid - }, - post: { - id: anyObjectId, - uuid: anyUuid, - url: anyString - } - } - }) - }) - .expect(({body}) => { - should(body.events.find(e => e.type === 'click_event')).not.be.undefined(); - }); - }); - // List Members it('Can browse', async function () { @@ -1519,7 +1469,7 @@ describe('Members API', function () { .expectStatus(200); const beforeMember = body2.members[0]; - assert.equal(beforeMember.tiers.length, 2, 'The member should have two products now'); + assert.equal(beforeMember.tiers.length, 2, 'The member should have two tiers now'); // Now try to remove only the complimentary one const compedPayload = { @@ -1801,6 +1751,9 @@ describe('Members API', function () { } } }, + { + type: 'signup_event' + }, { type: 'newsletter_event', data: { @@ -1811,9 +1764,6 @@ describe('Members API', function () { id: newsletters[0].id } } - }, - { - type: 'signup_event' } ]); @@ -2150,14 +2100,14 @@ describe('Members API', function () { 'content-disposition': anyString }); - res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/); + res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/); const csv = Papa.parse(res.text, {header: true}); should.exist(csv.data.find(row => row.name === 'Mr Egg')); should.exist(csv.data.find(row => row.name === 'Winston Zeddemore')); should.exist(csv.data.find(row => row.name === 'Ray Stantz')); should.exist(csv.data.find(row => row.email === 'member2@test.com')); - should.exist(csv.data.find(row => row.products.length > 0)); + should.exist(csv.data.find(row => row.tiers.length > 0)); should.exist(csv.data.find(row => row.labels.length > 0)); }); @@ -2171,14 +2121,14 @@ describe('Members API', function () { 'content-disposition': anyString }); - res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/); + res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/); const csv = Papa.parse(res.text, {header: true}); should.exist(csv.data.find(row => row.name === 'Mr Egg')); should.not.exist(csv.data.find(row => row.name === 'Egon Spengler')); should.not.exist(csv.data.find(row => row.name === 'Ray Stantz')); should.not.exist(csv.data.find(row => row.email === 'member2@test.com')); - // note that this member doesn't have products + // note that this member doesn't have tiers should.exist(csv.data.find(row => row.labels.length > 0)); }); diff --git a/ghost/core/test/e2e-api/admin/posts-legacy.test.js b/ghost/core/test/e2e-api/admin/posts-legacy.test.js index 345c7c50fee..24ae3eef2fa 100644 --- a/ghost/core/test/e2e-api/admin/posts-legacy.test.js +++ b/ghost/core/test/e2e-api/admin/posts-legacy.test.js @@ -112,7 +112,7 @@ describe('Posts API', function () { jsonResponse.posts[0], 'post', null, - ['authors', 'primary_author', 'email', 'tiers', 'newsletter', 'count'] + ['authors', 'primary_author', 'email', 'tiers', 'newsletter', 'count', 'sentiment'] ); localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination'); @@ -233,7 +233,7 @@ describe('Posts API', function () { should.exist(jsonResponse); should.exist(jsonResponse.posts); - localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, ['count']); + localUtils.API.checkResponse(jsonResponse.posts[0], 'post', null, ['count', 'sentiment']); jsonResponse.posts[0].authors[0].should.be.an.Object(); localUtils.API.checkResponse(jsonResponse.posts[0].authors[0], 'user'); diff --git a/ghost/core/test/e2e-api/admin/utils.js b/ghost/core/test/e2e-api/admin/utils.js index ff0841d9c54..a97b7ba0cdf 100644 --- a/ghost/core/test/e2e-api/admin/utils.js +++ b/ghost/core/test/e2e-api/admin/utils.js @@ -86,7 +86,8 @@ const expectedProperties = { 'email_only', 'tiers', 'newsletter', - 'count' + 'count', + 'sentiment' ], page: [ diff --git a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap index 8cff23befba..79df7855ee7 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap @@ -34,7 +34,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2952", + "content-length": "3066", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -76,7 +76,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2966", + "content-length": "3080", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -118,7 +118,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2856", + "content-length": "2970", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -160,7 +160,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3014", + "content-length": "3128", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -202,7 +202,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2980", + "content-length": "3094", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -244,7 +244,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2994", + "content-length": "3108", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -286,7 +286,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2908", + "content-length": "3022", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -328,7 +328,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent witho Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2856", + "content-length": "2970", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -372,6 +372,16 @@ Object { "type": Any, }, ], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 1, + "prev": null, + "total": 8, + }, + }, } `; @@ -391,7 +401,7 @@ exports[`Members API Member attribution Returns subscription created attribution Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14722", + "content-length": "14813", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -402,6 +412,16 @@ Object { exports[`Members API Member attribution empty initial activity feed 1: [body] 1`] = ` Object { "events": Array [], + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": null, + "pages": 0, + "prev": null, + "total": 0, + }, + }, } `; @@ -409,7 +429,7 @@ exports[`Members API Member attribution empty initial activity feed 2: [headers] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "13", + "content-length": "104", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", diff --git a/ghost/core/test/e2e-frontend/default_routes.test.js b/ghost/core/test/e2e-frontend/default_routes.test.js index c23e2bb1df7..0475ba16a35 100644 --- a/ghost/core/test/e2e-frontend/default_routes.test.js +++ b/ghost/core/test/e2e-frontend/default_routes.test.js @@ -326,7 +326,8 @@ describe('Default Frontend routing', function () { 'User-agent: *\n' + 'Sitemap: http://127.0.0.1:2369/sitemap.xml\nDisallow: /ghost/\n' + 'Disallow: /p/\n' + - 'Disallow: /email/\n' + 'Disallow: /email/\n' + + 'Disallow: /r/\n' ); }); diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap index ae13205bae0..796dff84693 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snap @@ -130,3 +130,73 @@ Object { }, } `; + +exports[`member.* events member.edited event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`member.* events member.edited event is triggered 2: [body] 1`] = ` +Object { + "member": Object { + "current": Object { + "avatar_image": null, + "comped": false, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "testemail3@example.com", + "email_count": 0, + "email_open_rate": null, + "email_opened_count": 0, + "geolocation": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "labels": Array [], + "last_seen_at": null, + "name": "Ghost", + "newsletters": Array [ + Object { + "body_font_category": "sans_serif", + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Default Newsletter", + "sender_email": null, + "sender_name": null, + "sender_reply_to": "newsletter", + "show_badge": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "slug": "default-newsletter", + "sort_order": 0, + "status": "active", + "subscribe_on_signup": true, + "title_alignment": "center", + "title_font_category": "sans_serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], + "note": "test note3", + "status": "free", + "subscribed": true, + "subscriptions": Array [], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + }, + "previous": Object { + "name": "Test Member3", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap index 5d97d971833..4fb172eeb96 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap @@ -38,7 +38,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, ], @@ -87,7 +87,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, "primary_tag": null, @@ -99,8 +99,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -109,13 +111,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -124,6 +129,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -132,7 +138,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -141,6 +147,218 @@ Object { } `; +exports[`page.* events page.edited event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`page.* events page.edited event is triggered 2: [body] 1`] = ` +Object { + "page": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + "primary_tag": null, + "published_at": null, + "slug": "updated-test-page", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "updated test page", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "slug": "testing-page-edited-webhook", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "testing page.edited webhook", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + exports[`page.* events page.scheduled event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", @@ -188,7 +406,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, ], @@ -197,9 +415,9 @@ Object { "codeinjection_head": null, "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { + "negative_feedback": 0, "paid_conversions": 0, "positive_feedback": 0, - "sentiment": 0, "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, @@ -251,7 +469,7 @@ Object { "tour": null, "twitter": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "website": null, }, "primary_tag": null, @@ -263,8 +481,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -273,13 +493,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -288,6 +511,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -296,7 +520,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -307,8 +531,224 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + +exports[`page.* events page.unpublished event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`page.* events page.unpublished event is triggered 2: [body] 1`] = ` +Object { + "page": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": Object { + "opened_count": null, + }, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "website": null, + }, + "primary_tag": null, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "slug": "testing-page-unpublished-webhook", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "testing page.unpublished webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "status": "published", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -317,13 +757,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -332,6 +775,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap index b21281c08b6..7def1d301f6 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/posts.test.js.snap @@ -102,8 +102,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -112,13 +114,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -127,6 +132,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -135,7 +141,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -220,7 +226,7 @@ Object { } `; -exports[`post.* events post.published event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.edited event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -230,7 +236,7 @@ Object { } `; -exports[`post.* events post.published event is triggered 2: [body] 1`] = ` +exports[`post.* events post.edited event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -277,10 +283,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -288,41 +294,20 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "excerpt": null, "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "html": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", "og_description": null, "og_image": null, "og_title": null, - "plaintext": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. - - -Header Level 2 - - 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - 2. Aliquam tincidunt mauris eu risus. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. - - -Header Level 3 - - * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - * Aliquam tincidunt mauris eu risus. - -#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -359,17 +344,18 @@ Header Level 3 "website": null, }, "primary_tag": null, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "webhookz", - "status": "published", + "published_at": null, + "slug": "testing-post-edited-webhook", + "status": "draft", "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -378,13 +364,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -393,27 +382,28 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "webhookz", + "title": "testing post.edited webhook - Updated", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, "previous": Object { - "published_at": null, - "status": "draft", "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -422,13 +412,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -437,16 +430,18 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], + "title": "testing post.edited webhook", "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, }, }, } `; -exports[`post.* events post.scheduled event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.published event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -456,7 +451,7 @@ Object { } `; -exports[`post.* events post.scheduled event is triggered 2: [body] 1`] = ` +exports[`post.* events post.published event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -503,10 +498,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -514,20 +509,41 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", "og_description": null, "og_image": null, "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -565,15 +581,18 @@ Object { }, "primary_tag": null, "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "slug": "testing-post-scheduled-webhook", - "status": "scheduled", + "reading_time": 1, + "slug": "webhookz", + "status": "published", "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -582,13 +601,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -597,15 +619,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "Testing post.scheduled webhook", + "title": "webhookz", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, @@ -616,8 +639,10 @@ Object { Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -626,13 +651,16 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -641,6 +669,7 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], @@ -650,7 +679,7 @@ Object { } `; -exports[`post.* events post.tag.attached event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.published.edited event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -660,7 +689,7 @@ Object { } `; -exports[`post.* events post.tag.attached event is triggered 2: [body] 1`] = ` +exports[`post.* events post.published.edited event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -707,10 +736,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -718,41 +747,20 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "excerpt": null, "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "html": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", "og_description": null, "og_image": null, "og_title": null, - "plaintext": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. - - -Header Level 2 - - 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - 2. Aliquam tincidunt mauris eu risus. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. - - -Header Level 3 - - * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - * Aliquam tincidunt mauris eu risus. - -#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -788,64 +796,19 @@ Header Level 3 "url": "http://127.0.0.1:2369/author/joe-bloggs/", "website": null, }, - "primary_tag": Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": Any, - "og_description": null, - "og_image": null, - "og_title": null, - "slug": Any, - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, - "visibility": Any, - }, - "published_at": null, - "reading_time": 1, - "slug": "test-post-tag-attached-webhook", - "status": "draft", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": Any, - "og_description": null, - "og_image": null, - "og_title": null, - "slug": Any, - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, - "visibility": Any, - }, - ], + "primary_tag": null, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "slug": "testing-post-published-edited-webhook", + "status": "published", + "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -854,13 +817,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -869,26 +835,28 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "test post tag attached webhook", + "title": "testing post published edited webhook - updated", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, "previous": Object { - "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -897,13 +865,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -912,15 +883,18 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], + "title": "testing post published edited webhook", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, }, }, } `; -exports[`post.* events post.tag.detached event is triggered 1: [headers] 1`] = ` +exports[`post.* events post.scheduled event is triggered 1: [headers] 1`] = ` Object { "accept-encoding": "gzip, deflate", "content-length": Any, @@ -930,7 +904,7 @@ Object { } `; -exports[`post.* events post.tag.detached event is triggered 2: [body] 1`] = ` +exports[`post.* events post.scheduled event is triggered 2: [body] 1`] = ` Object { "post": Object { "current": Object { @@ -977,10 +951,10 @@ Object { "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "count": Object { "clicks": 0, - "paid_conversions": 0, + "conversions": 0, + "negative_feedback": 0, "positive_feedback": 0, "sentiment": 0, - "signups": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "custom_excerpt": null, @@ -988,41 +962,20 @@ Object { "email_only": false, "email_segment": "all", "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "excerpt": null, "feature_image": null, "feature_image_alt": null, "feature_image_caption": null, "featured": false, "frontmatter": null, - "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "html": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "meta_description": null, "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", "og_description": null, "og_image": null, "og_title": null, - "plaintext": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. - - -Header Level 2 - - 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - 2. Aliquam tincidunt mauris eu risus. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. - - -Header Level 3 - - * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. - * Aliquam tincidunt mauris eu risus. - -#header h1 a{display: block;width: 300px;height: 80px;}", "primary_author": Object { "accessibility": null, "bio": "bio", @@ -1059,17 +1012,18 @@ Header Level 3 "website": null, }, "primary_tag": null, - "published_at": null, - "reading_time": 1, - "slug": "test-post-tag-detached-webhook", - "status": "draft", + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "slug": "testing-post-scheduled-webhook", + "status": "scheduled", "tags": Array [], "tiers": Array [ Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, "monthly_price_id": null, "name": "Default Product", "slug": "default-product", @@ -1078,13 +1032,16 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": 5000, "yearly_price_id": null, }, Object { "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, "description": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, "monthly_price_id": null, "name": "Free", "slug": "free", @@ -1093,44 +1050,1024 @@ Header Level 3 "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "visibility": "public", "welcome_page_url": null, + "yearly_price": null, "yearly_price_id": null, }, ], - "title": "test post tag detached webhook", + "title": "Testing post.scheduled webhook", "twitter_description": null, "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, "visibility": "public", }, "previous": Object { - "tags": Array [ + "published_at": null, + "status": "draft", + "tiers": Array [ Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, + "active": true, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", "description": null, - "feature_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": Any, - "og_description": null, - "og_image": null, - "og_title": null, - "slug": Any, - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, - "visibility": Any, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, }, ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + +exports[`post.* events post.tag.attached event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.tag.attached event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": Object { + "accent_color": null, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "name": Any, + "og_description": null, + "og_image": null, + "og_title": null, + "slug": Any, + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "visibility": Any, + }, + "published_at": null, + "reading_time": 1, + "slug": "test-post-tag-attached-webhook", + "status": "draft", + "tags": Array [ + Object { + "accent_color": null, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "name": Any, + "og_description": null, + "og_image": null, + "og_title": null, + "slug": Any, + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "visibility": Any, + }, + ], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "test post tag attached webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + }, + }, +} +`; + +exports[`post.* events post.tag.detached event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.tag.detached event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": null, + "published_at": null, + "reading_time": 1, + "slug": "test-post-tag-detached-webhook", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "test post tag detached webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "tags": Array [ + Object { + "accent_color": null, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "name": Any, + "og_description": null, + "og_image": null, + "og_title": null, + "slug": Any, + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "visibility": Any, + }, + ], + }, + }, +} +`; + +exports[`post.* events post.unpublished event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.unpublished event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": Object { + "opened_count": null, + }, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": "HTML Ipsum Presents + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. + + +Header Level 2 + + 1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + 2. Aliquam tincidunt mauris eu risus. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est. + + +Header Level 3 + + * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + * Aliquam tincidunt mauris eu risus. + +#header h1 a{display: block;width: 300px;height: 80px;}", + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": null, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 1, + "slug": "webhookz-2", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "webhookz", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "status": "published", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + }, +} +`; + +exports[`post.* events post.unscheduled event is triggered 1: [headers] 1`] = ` +Object { + "accept-encoding": "gzip, deflate", + "content-length": Any, + "content-type": "application/json", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "user-agent": StringMatching /Ghost\\\\/\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+\\\\s\\\\\\(https:\\\\/\\\\/github\\.com\\\\/TryGhost\\\\/Ghost\\\\\\)/, +} +`; + +exports[`post.* events post.unscheduled event is triggered 2: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "authors": Array [ + Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + ], + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "count": Object { + "clicks": 0, + "conversions": 0, + "negative_feedback": 0, + "positive_feedback": 0, + "sentiment": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": Object { + "opened_count": null, + }, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "primary_author": Object { + "accessibility": null, + "bio": "bio", + "comment_notifications": true, + "cover_image": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email": "jbloggs@example.com", + "facebook": null, + "free_member_signup_notification": true, + "id": "1", + "last_seen": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "location": "location", + "meta_description": null, + "meta_title": null, + "name": "Joe Bloggs", + "paid_subscription_canceled_notification": false, + "paid_subscription_started_notification": true, + "profile_image": "https://example.com/super_photo.jpg", + "roles": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": "Blog Owner", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Owner", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "slug": "joe-bloggs", + "status": "active", + "tour": null, + "twitter": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "http://127.0.0.1:2369/author/joe-bloggs/", + "website": null, + }, + "primary_tag": null, + "published_at": null, + "slug": "testing-post-unscheduled-webhook", + "status": "draft", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "title": "Testing post.unscheduled webhook", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + "previous": Object { + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "status": "scheduled", + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + ], + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, }, }, } diff --git a/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap b/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap index 5f322ef0faa..192c0100520 100644 --- a/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap +++ b/ghost/core/test/e2e-webhooks/__snapshots__/tags.test.js.snap @@ -33,7 +33,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "visibility": "public", }, "previous": Object {}, @@ -114,7 +114,7 @@ Object { "twitter_image": null, "twitter_title": null, "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\\\w\\+\\\\//, + "url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//, "visibility": "public", }, "previous": Object { diff --git a/ghost/core/test/e2e-webhooks/members.test.js b/ghost/core/test/e2e-webhooks/members.test.js index 9e17abf4ca8..6218b857b25 100644 --- a/ghost/core/test/e2e-webhooks/members.test.js +++ b/ghost/core/test/e2e-webhooks/members.test.js @@ -121,4 +121,50 @@ describe('member.* events', function () { } }); }); + + it('member.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/member-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'member.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('members/') + .body({ + members: [{ + name: 'Test Member3', + email: 'testemail3@example.com', + note: 'test note3' + }] + }) + .expectStatus(201); + + const id = res.body.members[0].id; + + await adminAPIAgent + .put('members/' + id) + .body({ + members: [{name: 'Ghost'}] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + member: { + current: buildMemberSnapshot(), + previous: { + updated_at: anyISODateTime + } + } + }); + }); }); \ No newline at end of file diff --git a/ghost/core/test/e2e-webhooks/pages.test.js b/ghost/core/test/e2e-webhooks/pages.test.js index cb64bd13ea2..644826eaac5 100644 --- a/ghost/core/test/e2e-webhooks/pages.test.js +++ b/ghost/core/test/e2e-webhooks/pages.test.js @@ -27,7 +27,8 @@ const buildAuthorSnapshot = (roles = false) => { const authorSnapshot = { last_seen: anyISODateTime, created_at: anyISODateTime, - updated_at: anyISODateTime + updated_at: anyISODateTime, + url: anyLocalURL }; if (roles) { @@ -120,6 +121,59 @@ describe('page.* events', function () { }); }); + it('page.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/page-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'page.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('pages/') + .body({ + pages: [ + { + title: 'testing page.edited webhook', + status: 'draft', + slug: 'testing-page-edited-webhook' + } + ] + }) + .expectStatus(201); + + const id = res.body.pages[0].id; + const updatedPage = res.body.pages[0]; + updatedPage.title = 'updated test page'; + updatedPage.slug = 'updated-test-page'; + + await adminAPIAgent + .put('pages/' + id) + .body({ + pages: [updatedPage] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + page: { + current: buildPageSnapshotWithTiers({ + published: false, + tiersCount: 2, + roles: true + }), + previous: buildPreviousPageSnapshotWithTiers(2) + } + }); + }); + it('page.scheduled event is triggered', async function () { const webhookURL = 'https://test-webhook-receiver.com/page-scheduled/'; await webhookMockReceiver.mock(webhookURL); @@ -171,4 +225,55 @@ describe('page.* events', function () { } }); }); + + it('page.unpublished event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/page-unpublished/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'page.unpublished', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('pages/') + .body({ + pages: [ + { + title: 'testing page.unpublished webhook', + status: 'published' + } + ] + }) + .expectStatus(201); + + const id = res.body.pages[0].id; + const previouslyPublishedPage = res.body.pages[0]; + previouslyPublishedPage.status = 'draft'; + + await adminAPIAgent + .put('pages/' + id) + .body({ + pages: [previouslyPublishedPage] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + page: { + current: buildPageSnapshotWithTiers({ + published: true, + tiersCount: 2, + roles: true + }), + previous: buildPreviousPageSnapshotWithTiers(2) + } + }); + }); }); diff --git a/ghost/core/test/e2e-webhooks/posts.test.js b/ghost/core/test/e2e-webhooks/posts.test.js index 6b98198284e..07b2c37a595 100644 --- a/ghost/core/test/e2e-webhooks/posts.test.js +++ b/ghost/core/test/e2e-webhooks/posts.test.js @@ -168,6 +168,59 @@ describe('post.* events', function () { }); }); + it('post.unpublished event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-unpublished/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.unpublished', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [ + { + title: 'webhookz', + status: 'published', + mobiledoc: fixtureManager.get('posts', 1).mobiledoc + } + ] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.status = 'draft'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: true, + tiersCount: 2 + }), + previous: buildPreviousPostSnapshotWithTiers({ + tiersCount: 2 + }) + } + }); + }); + it('post.added event is triggered', async function () { const webhookURL = 'https://test-webhook-receiver.com/post-added/'; await webhookMockReceiver.mock(webhookURL); @@ -298,6 +351,61 @@ describe('post.* events', function () { }); }); + it('post.unscheduled event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-unscheduled/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.unscheduled', + url: webhookURL + }); + + const published_at = moment().add(1, 'days').toISOString(); + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'Testing post.unscheduled webhook', + status: 'scheduled', + published_at: published_at + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const unrescheduledPost = res.body.posts[0]; + unrescheduledPost.status = 'draft'; + unrescheduledPost.published_at = null; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [unrescheduledPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: false, + tiersCount: 2 + }), + previous: { + published_at: anyISODateTime, + updated_at: anyISODateTime, + tiers: new Array(2).fill(tierSnapshot) + } + } + }); + }); + it('post.tag.attached event is triggered', async function () { const webhookURL = 'https://test-webhook-receiver.com/post-tag-attached/'; await webhookMockReceiver.mock(webhookURL); @@ -412,4 +520,103 @@ describe('post.* events', function () { } }); }); + + it('post.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'testing post.edited webhook', + status: 'draft' + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.title = 'testing post.edited webhook - Updated'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: false, + tiersCount: 2, + roles: true + }), + previous: buildPreviousPostSnapshotWithTiers({tiersCount: 2}) + } + }); + }); + + it('post.published.edited event is triggered', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-published-edited/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.published.edited', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'testing post published edited webhook', + status: 'published' + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.title = 'testing post published edited webhook - updated'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + webhookMockReceiver + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyNumber, + 'user-agent': anyGhostAgent + }) + .matchBodySnapshot({ + post: { + current: buildPostSnapshotWithTiers({ + published: true, + tiersCount: 2 + }), + previous: buildPreviousPostSnapshotWithTiers({ + tiersCount: 2 + }) + } + }); + }); }); diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js index 4865f31f0e3..8dfbd66464c 100644 --- a/ghost/core/test/integration/migrations/migration.test.js +++ b/ghost/core/test/integration/migrations/migration.test.js @@ -45,7 +45,7 @@ describe('Database Migration (special functions)', function () { const permissions = this.obj; // If you have to change this number, please add the relevant `havePermission` checks below - permissions.length.should.eql(106); + permissions.length.should.eql(108); permissions.should.havePermission('Export database', ['Administrator', 'DB Backup Integration']); permissions.should.havePermission('Import database', ['Administrator', 'DB Backup Integration']); @@ -179,6 +179,7 @@ describe('Database Migration (special functions)', function () { permissions.should.havePermission('Like comments', ['Administrator', 'Admin Integration']); permissions.should.havePermission('Unlike comments', ['Administrator', 'Admin Integration']); permissions.should.havePermission('Report comments', ['Administrator', 'Admin Integration']); + permissions.should.havePermission('Browse links', ['Administrator', 'Admin Integration']); }); describe('Populate', function () { diff --git a/ghost/core/test/integration/services/mega.test.js b/ghost/core/test/integration/services/mega.test.js index 395ff634cd6..7a46b500953 100644 --- a/ghost/core/test/integration/services/mega.test.js +++ b/ghost/core/test/integration/services/mega.test.js @@ -158,13 +158,13 @@ describe('MEGA', function () { // Do the actual replacements for the first member, so we don't have to worry about them anymore replacements.forEach((replacement) => { emailData[replacement.format] = emailData[replacement.format].replace( - replacement.match, + replacement.regexp, recipient[replacement.id] ); // Also force Mailgun format emailData[replacement.format] = emailData[replacement.format].replace( - `%recipient.${replacement.id}%`, + new RegExp(`%recipient.${replacement.id}%`, 'g'), recipient[replacement.id] ); }); @@ -190,6 +190,9 @@ describe('MEGA', function () { // Check if the link is a tracked link assert(href.includes('?m=' + memberUuid), href + ' is not tracked'); + // Check if this link is also present in the plaintext version (with the right replacements) + assert(emailData.plaintext.includes(href), href + ' is not present in the plaintext version'); + if (!firstLink) { firstLink = new URL(href); } diff --git a/ghost/core/test/regression/api/admin/members-importer.test.js b/ghost/core/test/regression/api/admin/members-importer.test.js index a6ea865bf44..cbeff1b0255 100644 --- a/ghost/core/test/regression/api/admin/members-importer.test.js +++ b/ghost/core/test/regression/api/admin/members-importer.test.js @@ -97,6 +97,7 @@ describe('Members Importer API', function () { .post(localUtils.API.getApiQuery(`members/upload/`)) .field('mapping[correo_electrpnico]', 'email') .field('mapping[nombre]', 'name') + .field('mapping[note]', 'note') .attach('membersfile', path.join(__dirname, '/../../../utils/fixtures/csv/members-with-mappings.csv')) .set('Origin', config.get('url')) .expect('Content-Type', /json/) @@ -135,7 +136,7 @@ describe('Members Importer API', function () { const importedMember1 = jsonResponse.members[0]; should(importedMember1.email).equal('member+mapped_1@example.com'); should(importedMember1.name).equal('Hannah'); - should(importedMember1.note).equal('no need to map me'); + should(importedMember1.note).equal('do map me'); importedMember1.subscribed.should.equal(true); importedMember1.comped.should.equal(false); importedMember1.subscriptions.should.not.be.undefined(); diff --git a/ghost/core/test/regression/api/admin/utils.js b/ghost/core/test/regression/api/admin/utils.js index 0c214b37efc..21c4e1c0cb3 100644 --- a/ghost/core/test/regression/api/admin/utils.js +++ b/ghost/core/test/regression/api/admin/utils.js @@ -66,7 +66,8 @@ const expectedProperties = { 'email_only', 'tiers', 'newsletter', - 'count' + 'count', + 'sentiment' ], user: [ 'id', diff --git a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap index 5e348005c4a..eb07d560ebb 100644 --- a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap +++ b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap @@ -70,7 +70,7 @@ Object { -