Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dotnetbuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: '7.0.101'
dotnet-version: '7.0'
- name: Setup Semantic version
id: nbgv
uses: dotnet/[email protected]
Expand Down
2 changes: 1 addition & 1 deletion src/Dockerfile.Aggregator
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
FROM mcr.microsoft.com/dotnet/runtime:7.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:7.0.101 AS build
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["Aggregator/Aggregator.csproj", "Aggregator/"]
RUN dotnet restore "Aggregator/Aggregator.csproj"
Expand Down
4 changes: 4 additions & 0 deletions src/Metering.BaseTypes/Json.fs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,16 @@ module Json =
match x with
| Monthly -> nameof(Monthly) |> Encode.string
| Annually -> nameof(Annually) |> Encode.string
| TwoYears -> "2-years" |> Encode.string
| ThreeYears -> "3-years" |> Encode.string

let Decoder : Decoder<RenewalInterval> =
Decode.string |> Decode.andThen (
function
| nameof(Monthly) -> Decode.succeed Monthly
| nameof(Annually) -> Decode.succeed Annually
| "2-years" -> Decode.succeed TwoYears
| "3-years" -> Decode.succeed ThreeYears
| invalid -> Decode.fail (sprintf "Failed to decode `%s`" invalid))

module ConsumedQuantity =
Expand Down
2 changes: 1 addition & 1 deletion src/Metering.BaseTypes/MarketplaceStructures.fs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ module MarketplaceSubmissionResult =

let requestFromError (error: MarketplaceSubmissionError) : MarketplaceRequest =
match error with
| DuplicateSubmission d -> d.PreviouslyAcceptedMessage.RequestData
| DuplicateSubmission d -> d.FailedRequest
| ResourceNotFound e -> e.RequestData
| Expired e -> e.RequestData
| Generic e -> e.RequestData
Expand Down
6 changes: 6 additions & 0 deletions src/Metering.BaseTypes/RenewalInterval.fs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@ open NodaTime
type RenewalInterval =
| Monthly
| Annually
| TwoYears
| ThreeYears

member this.Duration
with get() =
match this with
| Monthly -> Period.FromMonths(1)
| Annually -> Period.FromYears(1)
| TwoYears -> Period.FromYears(2)
| ThreeYears -> Period.FromYears(3)

member this.add (i: uint) : Period =
match this with
| Monthly -> Period.FromMonths(int i)
| Annually -> Period.FromYears(int i)
| TwoYears -> Period.FromYears(2 * (int i))
| ThreeYears -> Period.FromYears(3 * (int i))
138 changes: 138 additions & 0 deletions src/Metering.Tests/BusinessLogicTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

module Metering.NUnitTests.BusinessLogicTests

open System
open System.IO
open System.Text.RegularExpressions
open NUnit.Framework
open Metering.BaseTypes
open Metering.BaseTypes.EventHub

type E = EventHubEvent<MeteringUpdateEvent>
type S = MeterCollection

#nowarn "0342"

[<CustomComparison; StructuralEquality>]
type TestFile =
| Event of E
| State of (SequenceNumber * S)
interface IComparable<TestFile> with
member this.CompareTo other =
match (this, other) with
| (Event(e1), Event(e2)) -> e1.MessagePosition.SequenceNumber.CompareTo(e2.MessagePosition.SequenceNumber)
| (State(sn1, _), State(sn2, _)) -> sn1.CompareTo(sn2)
| (Event(e1), State(sn2, _)) -> if e1.MessagePosition.SequenceNumber = sn2 then -1 else e1.MessagePosition.SequenceNumber.CompareTo(sn2)
| (State(sn1, _), Event(e2)) -> if sn1 = e2.MessagePosition.SequenceNumber then 1 else sn1.CompareTo(e2.MessagePosition.SequenceNumber)
interface IComparable with
member this.CompareTo obj =
match obj with
| null -> 1
| :? TestFile as other -> (this :> IComparable<_>).CompareTo other
| _ -> invalidArg "obj" $"not a {nameof(TestFile)}"

let private readFile (name: string) : TestFile option =
let filename = (new FileInfo(name)).Name

// 000--event--2021-11-04--16-12-26--SubscriptionPurchased-2-year.json
let regexPatternEvent = "^(?<sequenceNumber>\d+)--event--(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})--(?<hour>\d{2})-(?<minute>\d{2})-(?<second>\d{2})--(?<comment>.*)\.json$"
let regexEvent = new Regex(pattern = regexPatternEvent, options = RegexOptions.ExplicitCapture)
let matchEvent = regexEvent.Match(input = filename)

// 000-state.json
let regexPatternState = "^(?<sequenceNumber>\d+)--state\.json$"
let regexState = new Regex(pattern = regexPatternState, options = RegexOptions.ExplicitCapture)
let matchState = regexState.Match(input = filename)

match (matchEvent.Success, matchState.Success) with
| (true, false) ->
let sequenceNumber = matchEvent.Groups["sequenceNumber"].Value |> SequenceNumber.Parse
let g (name: string) = matchEvent.Groups[name].Value |> System.Int32.Parse
let date = MeteringDateTime.create ("year" |> g) ("month" |> g) ("day" |> g) ("hour" |> g) ("minute" |> g) ("second" |> g)
let partitionId = "0"
let messagePosition = MessagePosition.create partitionId sequenceNumber date
let _comment = matchEvent.Groups["comment"].Value
let meteringUpdateEvent = File.ReadAllText(name) |> Json.fromStr<MeteringUpdateEvent>

E.createEventHub meteringUpdateEvent messagePosition None
|> Event
|> Some
| (false, true) ->
let sequenceNumber = matchState.Groups["sequenceNumber"].Value |> SequenceNumber.Parse
let state = File.ReadAllText(name) |> Json.fromStr<S>
(sequenceNumber, state) |> State |> Some
| _ -> None

let readTestFolder (path: string) : TestFile seq =
Directory.GetFiles(path, searchPattern = "*.json")
|> Seq.choose readFile
|> Seq.sort

[<Test>]
let ``Comparator works`` () =
let e = File.ReadAllText("data/BusinessLogic/RefreshIncludedQuantities/000--event--2021-11-04--16-12-26--SubscriptionPurchased-2-year.json") |> Json.fromStr<MeteringUpdateEvent>
let s = File.ReadAllText("data/BusinessLogic/RefreshIncludedQuantities/000--state.json") |> Json.fromStr<S>
let newEvent sequenceNumber =
let sequenceNumber = sequenceNumber|> SequenceNumber.Parse
let messagePosition = MessagePosition.create "0" sequenceNumber (MeteringDateTime.fromStr "2021-11-04T16:12:26Z")
E.createEventHub e messagePosition None |> Event
let newState sequenceNumber = TestFile.State (sequenceNumber|> SequenceNumber.Parse, s)

let l = [
newEvent "1"
newState "1"
newEvent "2"
newState "2"
]

Assert.IsTrue((l = List.sort l)) // Ensure the list is already sorted

let getEvents (files: TestFile seq) = files |> Seq.choose (function | Event e -> Some e | _ -> None)

let getStates (files: TestFile seq) = files |> Seq.choose (function | State s -> Some s | _ -> None)

let getState (files: TestFile seq) (idx: SequenceNumber) : MeterCollection =
files
|> getStates
|> Seq.find (fun (a, _) -> a = idx)
|> fun (_, i) -> i

let private checkFolder folder =
let files = readTestFolder folder
let events = getEvents files
let states = getStates files

let writeCalculatedState (sn: SequenceNumber) (s: S) =
let path = (new FileInfo(Path.Combine(folder, $"%03d{sn}-state-calculated.json"))).FullName
File.WriteAllText(path, contents = (Json.toStr 1 s))
eprintfn "Wrote actual state to %s" path
()

let applyMeterToStateAndAssertExpectedState (initialState: S) ((event,expectedState): E*S) : S =
let actualState = MeterCollectionLogic.handleMeteringEvent initialState event

try
Assert.AreEqual(expectedState, actualState)
with :? Exception ->
// When the comparison fails, we write the actual state into a file for better inspection
writeCalculatedState (event.MessagePosition.SequenceNumber) actualState
reraise()

actualState

// This is a sequence of tuples containing an Event and the expected state resulting from applying the event to the input state.
let eventsAndStates : (E * S) seq =
Seq.zip events states
|> Seq.map (fun (event, (_, state)) -> (event, state))

eventsAndStates
|> Seq.fold applyMeterToStateAndAssertExpectedState MeterCollection.Empty
|> ignore


[<Test>]
let ``check event sequence 'data/BusinessLogic/RefreshIncludedQuantities'`` () =
checkFolder "data/BusinessLogic/RefreshIncludedQuantities"

7 changes: 7 additions & 0 deletions src/Metering.Tests/Metering.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
<ProjectReference Include="..\Metering.Runtime\Metering.Runtime.fsproj" />
</ItemGroup>
<ItemGroup>
<None Include="data\BusinessLogic\RefreshIncludedQuantities\000--event--2021-11-04--16-12-26--SubscriptionPurchased-2-year.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="data\BusinessLogic\RefreshIncludedQuantities\000--state.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="data\InternalMessages\Ping TopOfHour.json"><CopyToOutputDirectory>Always</CopyToOutputDirectory></None>
<None Include="data\InternalMessages\Ping ProcessingStarting.json"><CopyToOutputDirectory>Always</CopyToOutputDirectory></None>
<None Include="data\InternalMessages\SubscriptionPurchased.json"><CopyToOutputDirectory>Always</CopyToOutputDirectory></None>
Expand Down Expand Up @@ -59,6 +65,7 @@
<None Include="data\MarketplaceMessages\MarketplaceGenericError.json"><CopyToOutputDirectory>Always</CopyToOutputDirectory></None>
<None Include="data\MarketplaceMessages\MarketplaceBatchResponseDTO.json"><CopyToOutputDirectory>Always</CopyToOutputDirectory></None>
<None Include="data\Capture\p9--2022-12-09--16-50-12.avro"><CopyToOutputDirectory>Always</CopyToOutputDirectory></None>
<Compile Include="BusinessLogicTests.fs" />
<Compile Include="BillingUnitTest.fs" />
<Compile Include="PartitionIdTests.fs" />
<Compile Include="WaterfallUnitTests.fs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"type": "SubscriptionPurchased",
"value": {
"subscription": {
"resourceId": "fdc778a6-1281-40e4-cade-4a5fc11f5440",
"subscriptionStart": "2021-11-04T16:12:26Z",
"renewalInterval": "2-years",
"plan": {
"planId": "mySaaSPlan",
"billingDimensions": {
"dimAsWeCallItAppInternally": {
"type": "simple",
"dimension": "someDimensionAsAzureMarketplaceCallsIt",
"included": 1000
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"lastProcessedMessage": {
"partitionId": "0",
"sequenceNumber": "0",
"partitionTimestamp": "2021-11-04T16:12:26Z"
},
"meters": [
{
"subscription": {
"resourceId": "fdc778a6-1281-40e4-cade-4a5fc11f5440",
"subscriptionStart": "2021-11-04T16:12:26Z",
"renewalInterval": "2-years",
"plan": {
"planId": "mySaaSPlan",
"billingDimensions": {
"dimAsWeCallItAppInternally": {
"type": "simple",
"meter": {
"included": {
"quantity": 1000,
"consumed": 0,
"lastUpdate": "2021-11-04T16:12:26Z"
}
},
"included": 1000,
"dimension": "someDimensionAsAzureMarketplaceCallsIt"
}
}
}
},
"usageToBeReported": [],
"lastProcessedMessage": {
"partitionId": "0",
"sequenceNumber": "0",
"partitionTimestamp": "2021-11-04T16:12:26Z"
}
}
],
"unprocessable": []
}
1 change: 0 additions & 1 deletion src/global.json

This file was deleted.