mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
fix: accept partially valid batch of heartbeats (resolve #824)
This commit is contained in:
@@ -18,7 +18,8 @@ headers {
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
[
|
||||
{
|
||||
"entity": "/home/user1/dev/proejct1/main.go",
|
||||
"project": "Project 1",
|
||||
"language": "Go",
|
||||
@@ -26,6 +27,17 @@ body:json {
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": 1750701956
|
||||
}
|
||||
"time": {{tsNow}}
|
||||
},
|
||||
{
|
||||
"entity": "/home/user1/dev/proejct1/main.go",
|
||||
"project": "Project 1",
|
||||
"language": "Go",
|
||||
"is_write": true,
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": {{ts1MonthAgo}}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ script:pre-request {
|
||||
function base64encode(str) {
|
||||
return Buffer.from(str, 'utf-8').toString('base64')
|
||||
}
|
||||
|
||||
bru.setVar('tsNow', parseInt(new Date().getTime() / 1000))
|
||||
bru.setVar('ts1MinAgo', parseInt((new Date().getTime() - 1000*60*1) / 1000))
|
||||
bru.setVar('ts2MinAgo', parseInt((new Date().getTime() - 1000*60*2) / 1000))
|
||||
bru.setVar('ts1HourAgo', parseInt((new Date().getTime() - 1000*60*60*1) / 1000))
|
||||
bru.setVar('ts1MonthAgo', parseInt((new Date().getTime() - 1000*60*60*24*30*1) / 1000))
|
||||
}
|
||||
|
||||
docs {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/stream"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,33 @@ type HeartbeatResponseViewModel struct {
|
||||
Responses [][]interface{} `json:"responses"`
|
||||
}
|
||||
|
||||
type HeartbeatCreationResult struct {
|
||||
Data *HeartbeatResponseData
|
||||
Status int
|
||||
}
|
||||
|
||||
type HeartbeatCreationResults []*HeartbeatCreationResult
|
||||
|
||||
func (l HeartbeatCreationResults) All() bool {
|
||||
return stream.FromSlice(l).AllMatch(func(r *HeartbeatCreationResult) bool {
|
||||
return r.Status >= 200 && r.Status < 300
|
||||
})
|
||||
}
|
||||
|
||||
func (l HeartbeatCreationResults) None() bool {
|
||||
return stream.FromSlice(l).AllMatch(func(r *HeartbeatCreationResult) bool {
|
||||
return r.Status < 200 || r.Status >= 300
|
||||
})
|
||||
}
|
||||
|
||||
var HeartbeatSuccess = &HeartbeatCreationResult{
|
||||
Status: http.StatusCreated,
|
||||
Data: &HeartbeatResponseData{
|
||||
Data: nil, // see comment in struct declaration for details
|
||||
Error: nil,
|
||||
},
|
||||
}
|
||||
|
||||
type HeartbeatResponseData struct {
|
||||
// response data actually looks like this: https://pastr.de/p/nyf6kj2e6843fbw4xkj4h4pj
|
||||
// however, for simplicity, we only implement the top-level fields (and the status code at index 1) and leave them empty
|
||||
|
||||
@@ -84,19 +84,23 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
opSys, editor, _ := utils.ParseUserAgent(userAgent)
|
||||
machineName := r.Header.Get("X-Machine-Name")
|
||||
|
||||
for _, hb := range heartbeats {
|
||||
creationResults := make(v1.HeartbeatCreationResults, len(heartbeats))
|
||||
|
||||
for i, hb := range heartbeats {
|
||||
if hb == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid heartbeat object"))
|
||||
return
|
||||
creationResults[i] = &v1.HeartbeatCreationResult{
|
||||
Status: http.StatusBadRequest,
|
||||
Data: &v1.HeartbeatResponseData{Error: "invalid heartbeat object"},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: unit test this
|
||||
if hb.UserAgent != "" {
|
||||
userAgent = hb.UserAgent
|
||||
localOpSys, localEditor, _ := utils.ParseUserAgent(userAgent)
|
||||
opSys = condition.TernaryOperator[bool, string](localOpSys != "", localOpSys, opSys)
|
||||
editor = condition.TernaryOperator[bool, string](localEditor != "", localEditor, editor)
|
||||
opSys = condition.Ternary[bool, string](localOpSys != "", localOpSys, opSys)
|
||||
editor = condition.Ternary[bool, string](localEditor != "", localEditor, editor)
|
||||
}
|
||||
if hb.Machine != "" {
|
||||
machineName = hb.Machine
|
||||
@@ -112,12 +116,15 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
hb.UserAgent = userAgent
|
||||
|
||||
if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid heartbeat object"))
|
||||
return
|
||||
creationResults[i] = &v1.HeartbeatCreationResult{
|
||||
Status: http.StatusBadRequest,
|
||||
Data: &v1.HeartbeatResponseData{Error: "invalid heartbeat object"},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
hb.Hashed()
|
||||
creationResults[i] = v1.HeartbeatSuccess
|
||||
}
|
||||
|
||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||
@@ -137,27 +144,23 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {}()
|
||||
if creationResults.None() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no valid heartbeat object given"))
|
||||
return
|
||||
}
|
||||
|
||||
helpers.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(&heartbeats))
|
||||
helpers.RespondJSON(w, r, http.StatusCreated, makeBulkResponse(creationResults))
|
||||
}
|
||||
|
||||
// construct wakatime response format https://wakatime.com/developers#heartbeats (well, not quite...)
|
||||
func constructSuccessResponse(heartbeats *[]*models.Heartbeat) *v1.HeartbeatResponseViewModel {
|
||||
func makeBulkResponse(results []*v1.HeartbeatCreationResult) *v1.HeartbeatResponseViewModel {
|
||||
vm := &v1.HeartbeatResponseViewModel{
|
||||
Responses: make([][]interface{}, len(*heartbeats)),
|
||||
Responses: make([][]interface{}, len(results)),
|
||||
}
|
||||
|
||||
for i := range *heartbeats {
|
||||
r := make([]interface{}, 2)
|
||||
r[0] = &v1.HeartbeatResponseData{
|
||||
Data: nil, // see comment in struct declaration for details
|
||||
Error: nil,
|
||||
}
|
||||
r[1] = http.StatusCreated
|
||||
vm.Responses[i] = r
|
||||
for i, r := range results {
|
||||
vm.Responses[i] = []interface{}{r.Data, r.Status}
|
||||
}
|
||||
|
||||
return vm
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ server:
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
heartbeat_max_age: 168h # 1 week
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
||||
@@ -12,7 +12,7 @@ server:
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
heartbeat_max_age: 168h # 1 week
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
||||
@@ -12,7 +12,7 @@ server:
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
heartbeat_max_age: 168h # 1 week
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
||||
@@ -12,7 +12,7 @@ server:
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
heartbeat_max_age: 168h # 1 week
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
||||
@@ -12,7 +12,7 @@ server:
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
heartbeat_max_age: 168h # 1 week
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
meta {
|
||||
name: Create heartbeats (all invalid)
|
||||
type: http
|
||||
seq: 11
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{BASE_URL}}/api/v1/users/current/heartbeats.bulk
|
||||
body: json
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{WRITEUSER_TOKEN}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
[{
|
||||
"entity": "/home/user1/dev/project1/main.go",
|
||||
"project": "wakapi",
|
||||
"language": "Go",
|
||||
"is_write": true,
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": {{ts4}}
|
||||
}]
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 400
|
||||
res.body: eq no valid heartbeat object given
|
||||
}
|
||||
@@ -34,6 +34,16 @@ body:json {
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": {{ts2}}
|
||||
},
|
||||
{
|
||||
"entity": "/home/user1/dev/project1/main.go",
|
||||
"project": "wakapi",
|
||||
"language": "Go",
|
||||
"is_write": true,
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": {{ts4}}
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -43,10 +53,13 @@ assert {
|
||||
|
||||
tests {
|
||||
test("Response body is correct", function () {
|
||||
expect(res.body.responses.length).to.eql(2);
|
||||
expect(res.body.responses.length).to.eql(3);
|
||||
expect(res.body.responses[0].length).to.eql(2);
|
||||
expect(res.body.responses[1].length).to.eql(2);
|
||||
expect(res.body.responses[2].length).to.eql(2);
|
||||
expect(res.body.responses[0][1]).to.eql(201);
|
||||
expect(res.body.responses[1][1]).to.eql(201);
|
||||
expect(res.body.responses[2][0].error).to.not.be.empty;
|
||||
expect(res.body.responses[2][1]).to.eql(400);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ body:json {
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": 1640995200
|
||||
"time": {{tsStartOfYesterday}}
|
||||
},
|
||||
{
|
||||
"entity": "/home/user1/dev/project1/main.go",
|
||||
@@ -33,7 +33,7 @@ body:json {
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": 1641074400
|
||||
"time": {{tsLateNightYesterday}}
|
||||
},
|
||||
{
|
||||
"entity": "/home/user1/dev/project1/main.go",
|
||||
@@ -43,7 +43,7 @@ body:json {
|
||||
"type": "file",
|
||||
"category": null,
|
||||
"branch": null,
|
||||
"time": 1641081600
|
||||
"time": {{tsStartOfDay}}
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2022-01-01
|
||||
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date={{yesterdayDate}}
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:query {
|
||||
date: 2022-01-01
|
||||
date: {{yesterdayDate}}
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
@@ -24,8 +24,8 @@ assert {
|
||||
|
||||
tests {
|
||||
test("Response body is correct", function () {
|
||||
const date = new Date(bru.getVar('yesterdayDateIso'))
|
||||
expect(res.body.timezone).to.eql(bru.getCollectionVar('TZ'));
|
||||
var date = new Date("2022-01-01T00:00:00+0100")
|
||||
expect(new Date(res.body.start)).to.eql(date);
|
||||
expect(new Date(res.body.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));
|
||||
expect(res.body.data.length).to.eql(2);
|
||||
|
||||
@@ -32,12 +32,15 @@ script:pre-request {
|
||||
return offset / 1000 / 60;
|
||||
}
|
||||
|
||||
const utcOffset = getTimezoneOffset(userZone);
|
||||
const utcOffset = getTimezoneOffset(userZone)
|
||||
bru.setVar('utcOffset', utcOffset)
|
||||
|
||||
const now = moment().utcOffset(utcOffset)
|
||||
const startOfDay = now.clone().startOf('day')
|
||||
const endOfDay = now.clone().endOf('day')
|
||||
const endOfTomorrow = now.clone().add(1, 'd').endOf('day')
|
||||
const startOfYesterday = now.clone().add(-1, 'day').startOf('day')
|
||||
const endOfYesterday = now.clone().add(-1, 'day').endOf('day')
|
||||
|
||||
// Auth stuff
|
||||
const readApiKey = bru.getCollectionVar('READUSER_API_KEY')
|
||||
@@ -60,7 +63,11 @@ script:pre-request {
|
||||
bru.setVar('tsNowMinus2Min', now.clone().add(-2, 'm').format('x') / 1000)
|
||||
bru.setVar('tsNowMinus3Min', now.clone().add(-3, 'm').format('x') / 1000)
|
||||
bru.setVar('tsStartOfDay', startOfDay.format('x') / 1000)
|
||||
bru.setVar('tsLateNightToday', startOfDay.clone().add(22, 'h').format('x') / 1000)
|
||||
bru.setVar('tsEndOfDay', endOfDay.format('x') / 1000)
|
||||
bru.setVar('tsStartOfYesterday', startOfYesterday.format('x') / 1000)
|
||||
bru.setVar('tsLateNightYesterday', startOfYesterday.clone().add(22, 'h').format('x') / 1000)
|
||||
bru.setVar('tsEndOfYesterday', endOfYesterday.format('x') / 1000)
|
||||
bru.setVar('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)
|
||||
bru.setVar('tsStartOfDayIso', startOfDay.toISOString())
|
||||
bru.setVar('tsEndOfDayIso', endOfDay.toISOString())
|
||||
@@ -68,4 +75,9 @@ script:pre-request {
|
||||
bru.setVar('ts1', now.clone().startOf('hour').format('x') / 1000)
|
||||
bru.setVar('ts2', now.clone().startOf('hour').add(1, 'm').format('x') / 1000)
|
||||
bru.setVar('ts3', now.clone().startOf('hour').add(2, 'm').format('x') / 1000)
|
||||
bru.setVar('ts4', now.clone().add(-1, 'months').format('x') / 1000) // last month
|
||||
bru.setVar('todayDate', startOfDay.format('YYYY-MM-DD'))
|
||||
bru.setVar('todayDateIso', startOfDay.toISOString(true))
|
||||
bru.setVar('yesterdayDate', startOfYesterday.format('YYYY-MM-DD'))
|
||||
bru.setVar('yesterdayDateIso', startOfYesterday.toISOString(true))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user